dev: promote stage release to production (#155)

* refractor: removed modules from user.context

* refractor: removed cycles from user context

* refractor: removed state from user context

* feat: implement channel protocol for tracking issue-activites

* refactor: remove blocking code and add todo

* refactor: refactor the consumer with function modules

* feat: add columns for identifiers for easier redirection

* style: minor padding, coloring and consistency changes

* feat: track blocker issues

* feat: track issue after creation

* feat: add runworker in procfile

* refractor: moved all context provider to _app for more clarity

* dev: added our icons

* refractor: removed issues from user context

* refactor: rename db names to plural and remove admin register file

* refactor: integrate permission layer in endpoints

* feat: create product email html templates

* refractor: changed to getServerSide from getInitialProps, removed unused component imports and minor refractoring

* feat: remirror added

* feat: workspace member user details endpoint

* fix: resolved build issue

* refactor:  remove www

* feat: workspace details on user endpoint

* feat: added authorization in project settings

refractor: improved code readability

* fix: removed hard-coded workspace slug value, and added workspace in user interface

* refactor: invitation workflow for already existing users

* feat: modified remirror, fix: issue details sidebar

* fix: merge conflicts

* fix: merge conflicts

* fix: added missing dependencies

* refactor: remove user dependency from invitations

* refactor: issue description context is updated with manager

* dev: redis instance rewrite for ssl settings and remove REDIS_TLS env variable

* chore: upgrade python package requirements

* dev: added new migrations for changes

* dev: ssl config for django channels redis connection

* chore: upgrade channels requirements

* refactor: better function for connecting with redis ssl django channels

* chore: cleanup on manifest file

* revert: user endpoint changes

* build: setup asgi

* refactor: update invitation endpoint to do bulk operations

* style: cycles page, custom listbox, issue details page

* refractor: removed folder that were moved to workspaceSlug

* dev: uvicorn in requirements

* Update index.tsx

* refactor: get workspace slug on user endpoint

* fix: workspace slug redirections and slug value in user context

* fix: user context bugs, drag and drop in cycles and modules

* fix: merge conflicts

* fix: user context and create issue modal

* refactor: add extra columns for json and html description and script for back migrating old issues

* refactor: move all 500 errors to 400

* refractor: removed active project, active workspace, projects, and workspaces from user context

* refractor: change from /home to /, added home page redirection logic

added explict GET method on fetch request, and fixed invitation page not fetching all invitations

* fix: passing project id in command palette

* style: home page, feat: image in remirror

* fix: bugs

* chore: remove test_runner workflow from github actions

* dev: update Procfile worker count and python runtime upgrade

* refactor: update response from 404 to 403

* feat: filtering using both name and issue identifier in command palette

showing my issues instead of project issue in command palette, hiding again according to route in command palette

* fix: mutation on different CRUD operations

* fix: redirection in my issues pages

* feat: added authorization in workspace settings, moved command palette to app-layout

* feat: endpoint and column to store my issue props

* style: authorization new design,

fix: made whole button on authorization page clickable, lib/auth on unsuccessful api call redirecting to error page

* feat: return project details on modules and cycles

* fix: create cycle and state coming below issue modal, showing loader for rich text editor

refractor: changed from sprint to cycle in issue type

* fix: issue delete mustation

and some code refractor

* fix: mutation bugs, remirror bugs, style: consistent droopdowns and buttons

* feat: user role in model

* dev: added new migrations

* fix: add url for workspace availability check

* feat: onboarding screens

* fix: update url for workspace name check and add authentication layer and
fix invitation endpoint

* refactor: bulk invitations message

* refactor: response on workspace invitarions

* refactor: update identifier endpoint

* refactor: invitations endpoint

* feat: onboarding logic and validations

* fix: email striep

* dev: added workspace space member unique_together

* chore: back populate neccesary data for description field

* feat: emoji-picker gets close on select, public will be default option in create project

* fix: update error in project creation

* fix: mutation error on issue count in kanban view

some minor code refractoring

* fix: module bugs

* fix: issue activities and issue comments mutation handled at issue detail

* fix: error message for creating updates without permissions

* fix: showing no user left to invite in project invite

fix: - mutation in project settings control, style: - showing loader in project settings controller, - showing request pending for user that hasn't accepted invitation

* refactor: file asset upload directory

* fix: update last workspace id on user invitation accept

* style: onboarding screens

* style: cycles, issue activity

* feat: add json and html column in issue comments

* fix: submitting create issue modal on enter click, project not getting deselected

* feat: file size validator

* fix: emoji picker not closing on all emoji select

* feat: added validation in identifier such that it only accept uppercase text

* dev: commenting is now richer

* fix: shortcuts not getting opened in settings layouts

* style: showing sidebar on unauthorized pages

* fix: error code on exception

* fix: add issue button is working on my issues pages

* feat: new way of assets

* fix: updated activity content for description field

* fix: mutation on project settings control

style: blocker and blocked changed to outline button

* fix: description activity logging

* refactor: check for workspace slug on workspace creation

* fix: typo on workspace url check

* fix: workspace name uniqueness

* fix: remove workspace from read only field

* fix: file upload endpoint, workspace slug check

* chore: drop unique_together constraint for name and workspace

* chore: settings files cleanup and use PubSub backend on django channels

* chore: change in channels backend

* refactor: issue activity api to combine comments

* fix: instance created at key

* fix: result list

* style: create project, cycle modal, view dropdown

* feat: merged issue activities and issue comments into a single section

* fix: remirror dynamic update of issue description

* fix: removed commented code

* fix: issue acitivties mutation

* fix: empty comments cant be submitted

* fix: workspace avatar has been updated while loading

* refactor: update docker-compose to run redis and database in heroku and docker environment

* refactor: removesingle docker file configuration

* refactor: update take off script to run in asgi

* docs: added workspace, quickstart documentation

* fix: reading editor values on focus out

* refactor: cleanup environment variables and create .env.example

* refactor: add extra variables in example env

* fix: warning and erros on console

lazy loading images with low priority, added validation on onboarding for user to either join or create workspace, on onboarding user can't click button while form is getting submitted, profile page going into loading state when updated, refractor: made some state local, removed unnecessary console logs and comments, changed some variable and function name to make more sence

* feat: env examples

* fix: workspace member does not exist

* fi: remove pagination from issue list api

* refactor: remove env example from root

* feat: documentation for projects on plane

* feat: create code of conduct and contributing guidelines

* fix: update docker setup to check handle redis

* revert: bring back pagination to avoid breaking

* feat: made image uploader modal, used it in profile page and workspace page,

delete project from project settings page, join project modal in project list page

* feat: create workspace page, style: made ui consistent

* style: updated onboarding and create workspace page design

* style: responsive sidebar

* fix: updated ui imports
This commit is contained in:
Vamsi Kurama 2023-01-10 23:55:47 +05:30 committed by GitHub
parent a960ddedf7
commit bef166a65f
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
395 changed files with 20119 additions and 18322 deletions

View File

@ -1,53 +0,0 @@
name: Plane Tests
on:
push:
pull_request:
branches:
- master
jobs:
test:
runs-on: ubuntu-latest
services:
postgres:
image: postgres:latest
env:
POSTGRES_USER: postgres
POSTGRES_PASSWORD: postgres
POSTGRES_DB: github_actions
ports:
- 5432:5432
options: --health-cmd pg_isready --health-interval 10s --health-timeout 5s --health-retries 5
redis:
image: redis:latest
env:
REDIS_HOST: localhost
REDIS_PORT: 6379
ports:
- 6379:6379
options: --health-cmd "redis-cli ping"
--health-interval 10s
--health-timeout 5s
--health-retries 5
steps:
- uses: actions/checkout@v3
- name: Set up Python 3.9
uses: actions/setup-python@v4
with:
python-version: 3.9
- name: psycopg2 prerequisites
run: sudo apt-get install libpq-dev
- name: Install dependencies
working-directory: ./apiserver
run: |
python -m pip install --upgrade pip
pip install -r requirements/test.txt
- name: Run Tests
working-directory: ./apiserver
env:
SECRET_KEY: ${{ secrets.SECRET_KEY }}
run: coverage run --source='.' manage.py test --settings=plane.settings.test

128
CODE_OF_CONDUCT.md Normal file
View File

@ -0,0 +1,128 @@
# Contributor Covenant Code of Conduct
## Our Pledge
We as members, contributors, and leaders pledge to make participation in our
community a harassment-free experience for everyone, regardless of age, body
size, visible or invisible disability, ethnicity, sex characteristics, gender
identity and expression, level of experience, education, socio-economic status,
nationality, personal appearance, race, religion, or sexual identity
and orientation.
We pledge to act and interact in ways that contribute to an open, welcoming,
diverse, inclusive, and healthy community.
## Our Standards
Examples of behavior that contributes to a positive environment for our
community include:
* Demonstrating empathy and kindness toward other people
* Being respectful of differing opinions, viewpoints, and experiences
* Giving and gracefully accepting constructive feedback
* Accepting responsibility and apologizing to those affected by our mistakes,
and learning from the experience
* Focusing on what is best not just for us as individuals, but for the
overall community
Examples of unacceptable behavior include:
* The use of sexualized language or imagery, and sexual attention or
advances of any kind
* Trolling, insulting or derogatory comments, and personal or political attacks
* Public or private harassment
* Publishing others' private information, such as a physical or email
address, without their explicit permission
* Other conduct which could reasonably be considered inappropriate in a
professional setting
## Enforcement Responsibilities
Community leaders are responsible for clarifying and enforcing our standards of
acceptable behavior and will take appropriate and fair corrective action in
response to any behavior that they deem inappropriate, threatening, offensive,
or harmful.
Community leaders have the right and responsibility to remove, edit, or reject
comments, commits, code, wiki edits, issues, and other contributions that are
not aligned to this Code of Conduct, and will communicate reasons for moderation
decisions when appropriate.
## Scope
This Code of Conduct applies within all community spaces, and also applies when
an individual is officially representing the community in public spaces.
Examples of representing our community include using an official e-mail address,
posting via an official social media account, or acting as an appointed
representative at an online or offline event.
## Enforcement
Instances of abusive, harassing, or otherwise unacceptable behavior may be
reported to the community leaders responsible for enforcement at
hello@plane.so.
All complaints will be reviewed and investigated promptly and fairly.
All community leaders are obligated to respect the privacy and security of the
reporter of any incident.
## Enforcement Guidelines
Community leaders will follow these Community Impact Guidelines in determining
the consequences for any action they deem in violation of this Code of Conduct:
### 1. Correction
**Community Impact**: Use of inappropriate language or other behavior deemed
unprofessional or unwelcome in the community.
**Consequence**: A private, written warning from community leaders, providing
clarity around the nature of the violation and an explanation of why the
behavior was inappropriate. A public apology may be requested.
### 2. Warning
**Community Impact**: A violation through a single incident or series
of actions.
**Consequence**: A warning with consequences for continued behavior. No
interaction with the people involved, including unsolicited interaction with
those enforcing the Code of Conduct, for a specified period of time. This
includes avoiding interactions in community spaces as well as external channels
like social media. Violating these terms may lead to a temporary or
permanent ban.
### 3. Temporary Ban
**Community Impact**: A serious violation of community standards, including
sustained inappropriate behavior.
**Consequence**: A temporary ban from any sort of interaction or public
communication with the community for a specified period of time. No public or
private interaction with the people involved, including unsolicited interaction
with those enforcing the Code of Conduct, is allowed during this period.
Violating these terms may lead to a permanent ban.
### 4. Permanent Ban
**Community Impact**: Demonstrating a pattern of violation of community
standards, including sustained inappropriate behavior, harassment of an
individual, or aggression toward or disparagement of classes of individuals.
**Consequence**: A permanent ban from any sort of public interaction within
the community.
## Attribution
This Code of Conduct is adapted from the [Contributor Covenant][homepage],
version 2.0, available at
https://www.contributor-covenant.org/version/2/0/code_of_conduct.html.
Community Impact Guidelines were inspired by [Mozilla's code of conduct
enforcement ladder](https://github.com/mozilla/diversity).
[homepage]: https://www.contributor-covenant.org
For answers to common questions about this code of conduct, see the FAQ at
https://www.contributor-covenant.org/faq. Translations are available at
https://www.contributor-covenant.org/translations.

59
CONTRIBUTING.md Normal file
View File

@ -0,0 +1,59 @@
# Contributing to Plane
Thank you for showing an interest in contributing to Plane! All kinds of contributions are valuable to us. In this guide, we will cover how you can quickly onboard and make your first contribution.
## Submitting an issue
Before submitting a new issue, please search the [issues](https://github.com/makeplane/plane/issues) tab. Maybe an issue or discussion already exists and might inform you of workarounds. Otherwise, you can give new informplaneation.
While we want to fix all the [issues](https://github.com/makeplane/plane/issues), before fixing a bug we need to be able to reproduce and confirm it. Please provide us with a minimal reproduction scenario using a repository or [Gist](https://gist.github.com/). Having a live, reproducible scenario gives us the information without asking questions back & forth with additional questions like:
- 3rd-party libraries being used and their versions
- a use-case that fails
Without said minimal reproduction, we won't be able to investigate all [issues](https://github.com/makeplane/plane/issues), and the issue might not be resolved.
You can open a new issue with this [issue form](https://github.com/makeplane/plane/issues/new).
## Projects setup and Architecture
### Requirements
- Node.js version v16.18.0
- Python version 3.8+
- Postgres version v14
- Redis version v6.2.7
- pnpm version 7.22.0
### Setup the project
The project is a monorepo, with backend api and frontend in a single repo.
The backend is a django project which is kept inside apiserver
## Missing a Feature?
If a feature is missing, you can directly _request_ a new one [here](https://github.com/makeplane/plane/issues/new?assignees=&labels=feature&template=feature_request.yml&title=%F0%9F%9A%80+Feature%3A+). You also can do the same by choosing "🚀 Feature" when raising a [New Issue](https://github.com/makeplane/plane/issues/new/choose) on our GitHub Repository.
If you would like to _implement_ it, an issue with your proposal must be submitted first, to be sure that we can use it. Please consider the guidelines given below.
## Coding guidelines
To ensure consistency throughout the source code, please keep these rules in mind as you are working:
- All features or bug fixes must be tested by one or more specs (unit-tests).
- We use [Eslint default rule guide](https://eslint.org/docs/rules/), with minor changes. An automated formatter is available using prettier.
## Need help? Questions and suggestions
Questions, suggestions, and thoughts are most welcome. We can also be reached in our [Discord Server](https://discord.com/invite/A92xrEGCge).
## Ways to contribute
- Try Plane Cloud and the self hosting platform and give feedback
- Add new integrations
- Help with open [issues](https://github.com/makeplane/plane/issues) or [create your own](https://github.com/makeplane/plane/issues/new/choose)
- Share your thoughts and suggestions with us
- Help create tutorials and blog posts
- Request a feature by submitting a proposal
- Report a bug
- **Improve documentation** - fix incomplete or missing [docs](https://docs.plane.so/), bad wording, examples or explanations.

View File

@ -1,138 +0,0 @@
FROM node:18-alpine AS builder
RUN apk add --no-cache libc6-compat
RUN apk update
# Set working directory
WORKDIR /app
RUN apk add curl
COPY ./apps ./apps
COPY ./package.json ./package.json
COPY ./.eslintrc.json ./.eslintrc.json
COPY ./yarn.lock ./yarn.lock
RUN yarn global add turbo
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
# First install the dependencies (as they change less often)
COPY .gitignore .gitignore
COPY --from=builder /app/out/json/ .
COPY --from=builder /app/out/yarn.lock ./yarn.lock
RUN yarn install
# Build the project
COPY --from=builder /app/out/full/ .
COPY turbo.json turbo.json
RUN yarn turbo run build --filter=app...
FROM python:3.8.14-alpine3.16 AS runner
ENV SECRET_KEY ${SECRET_KEY}
ENV DATABASE_URL ${DATABASE_URL}
ENV REDIS_URL ${REDIS_URL}
ENV EMAIL_HOST ${EMAIL_HOST}
ENV EMAIL_HOST_USER ${EMAIL_HOST_USER}
ENV EMAIL_HOST_PASSWORD ${EMAIL_HOST_PASSWORD}
ENV AWS_REGION ${AWS_REGION}
ENV AWS_ACCESS_KEY_ID ${AWS_ACCESS_KEY_ID}
ENV AWS_SECRET_ACCESS_KEY ${AWS_SECRET_ACCESS_KEY}
ENV AWS_S3_BUCKET_NAME ${AWS_S3_BUCKET_NAME}
ENV SENTRY_DSN ${SENTRY_DSN}
ENV WEB_URL ${WEB_URL}
ENV DISABLE_COLLECTSTATIC ${DISABLE_COLLECTSTATIC}
ENV GITHUB_CLIENT_SECRET ${GITHUB_CLIENT_SECRET}
ENV NEXT_PUBLIC_GITHUB_ID ${NEXT_PUBLIC_GITHUB_ID}
ENV NEXT_PUBLIC_GOOGLE_CLIENTID ${NEXT_PUBLIC_GOOGLE_CLIENTID}
ENV NEXT_PUBLIC_API_BASE_URL ${NEXT_PUBLIC_API_BASE_URL}
# Frontend
RUN apk --update --no-cache add \
"libpq~=14" \
"libxslt~=1.1" \
"nodejs-current~=18" \
"xmlsec~=1.2"
WORKDIR /app
# Don't run production as root
RUN addgroup -S plane && \
adduser -S captain -G plane
USER captain
COPY --from=installer /app/apps/app/next.config.js .
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/static ./apps/app/.next/static
EXPOSE 3000
# Backend
USER root
ENV PYTHONDONTWRITEBYTECODE 1
ENV PYTHONUNBUFFERED 1
ENV PIP_DISABLE_PIP_VERSION_CHECK=1
COPY ./apiserver/requirements.txt ./
COPY ./apiserver/requirements ./requirements
RUN apk add libffi-dev
RUN apk --update --no-cache --virtual .build-deps add \
"bash~=5.1" \
"g++~=11.2" \
"gcc~=11.2" \
"cargo~=1.60" \
"git~=2" \
"make~=4.3" \
"postgresql13-dev~=13" \
"libc-dev" \
"linux-headers" \
&& \
pip install -r requirements.txt --compile --no-cache-dir \
&& \
apk del .build-deps
RUN chown captain.plane /app
# Add in Django deps and generate Django's static files
COPY ./apiserver/manage.py manage.py
COPY ./apiserver/plane plane/
COPY ./apiserver/templates templates/
COPY ./apiserver/gunicorn.config.py ./
USER root
RUN apk --update --no-cache add "bash~=5.1"
COPY ./bin ./bin/
USER captain
# Expose container port and run entry point script
EXPOSE 8000
USER root
RUN apk --update add supervisor
ADD /supervisor /src/supervisor
CMD ["supervisord","-c","/src/supervisor/service_script.conf"]

18
apiserver/.env.example Normal file
View File

@ -0,0 +1,18 @@
# Backend
SECRET_KEY="<-- django secret -->"
EMAIL_HOST="<-- email smtp -->"
EMAIL_HOST_USER="<-- email host user -->"
EMAIL_HOST_PASSWORD="<-- email host password -->"
AWS_REGION="<-- aws region -->"
AWS_ACCESS_KEY_ID="<-- aws access key -->"
AWS_SECRET_ACCESS_KEY="<-- aws secret acess key -->"
AWS_S3_BUCKET_NAME="<-- aws s3 bucket name -->"
SENTRY_DSN="<-- sentry dsn -->"
WEB_URL="<-- frontend web url -->"
GITHUB_CLIENT_SECRET="<-- github secret -->"
DISABLE_COLLECTSTATIC=1
DOCKERIZED=0 //True if running docker compose else 0

View File

@ -48,6 +48,9 @@ COPY gunicorn.config.py ./
USER root
RUN apk --update --no-cache add "bash~=5.1"
COPY ./bin ./bin/
RUN chmod +x ./bin/channel-worker ./bin/takeoff ./bin/worker
USER captain
# Expose container port and run entry point script

View File

@ -1,2 +1,3 @@
web: gunicorn plane.wsgi -k gthread --workers 8 --bind 0.0.0.0:$PORT --config gunicorn.config.py --max-requests 10000 --max-requests-jitter 1000 --access-logfile -
worker: python manage.py rqworker
web: gunicorn -w 4 -k uvicorn.workers.UvicornWorker plane.asgi:application --bind 0.0.0.0:$PORT --config gunicorn.config.py --max-requests 10000 --max-requests-jitter 1000 --access-logfile -
worker: python manage.py rqworker
channel-worker: python manage.py runworker issue-activites

View File

@ -0,0 +1,42 @@
# All the python scripts that are used for back migrations
from plane.db.models import Issue, IssueComment
# Update description and description html values for old descriptions
def update_description():
try:
issues = Issue.objects.all()
updated_issues = []
for issue in issues:
issue.description_html = f"<p>{issue.description}</p>"
issue.description_stripped = issue.description
updated_issues.append(issue)
Issue.objects.bulk_update(
updated_issues, ["description_html", "description_stripped"], batch_size=100
)
print("Success")
except Exception as e:
print(e)
print("Failed")
def update_comments():
try:
issue_comments = IssueComment.objects.all()
updated_issue_comments = []
for issue_comment in issue_comments:
issue_comment.comment_html = f"<p>{issue_comment.comment_stripped}</p>"
updated_issue_comments.append(issue_comment)
Issue.objects.bulk_update(
updated_issue_comments, ["comment_html"], batch_size=100
)
print("Success")
except Exception as e:
print(e)
print("Failed")

6
apiserver/bin/channel-worker Executable file
View File

@ -0,0 +1,6 @@
#!/bin/bash
set -e
python manage.py wait_for_db
python manage.py migrate
python manage.py runworker issue-activites

View File

@ -2,4 +2,4 @@
set -e
python manage.py wait_for_db
python manage.py migrate
exec gunicorn plane.wsgi -k gthread --workers 8 --bind 0.0.0.0:8000 --config gunicorn.config.py --max-requests 10000 --max-requests-jitter 1000 --access-logfile -
exec gunicorn -w 8 -k uvicorn.workers.UvicornWorker plane.asgi:application --bind 0.0.0.0:8000 --config gunicorn.config.py --max-requests 1200 --max-requests-jitter 1000 --access-logfile -

View File

@ -0,0 +1 @@
from .issue_consumer import IssueConsumer

View File

@ -0,0 +1,547 @@
from channels.generic.websocket import SyncConsumer
import json
from plane.db.models import IssueActivity, Project, User, Issue, State, Label
class IssueConsumer(SyncConsumer):
# Track Chnages in name
def track_name(
self,
requested_data,
current_instance,
issue_id,
project,
actor,
issue_activities,
):
if current_instance.get("name") != requested_data.get("name"):
issue_activities.append(
IssueActivity(
issue_id=issue_id,
actor=actor,
verb="updated",
old_value=current_instance.get("name"),
new_value=requested_data.get("name"),
field="name",
project=project,
workspace=project.workspace,
comment=f"{actor.email} updated the start date to {requested_data.get('name')}",
)
)
# Track changes in parent issue
def track_parent(
self,
requested_data,
current_instance,
issue_id,
project,
actor,
issue_activities,
):
if current_instance.get("parent") != requested_data.get("parent"):
if requested_data.get("parent") == None:
old_parent = Issue.objects.get(pk=current_instance.get("parent"))
issue_activities.append(
IssueActivity(
issue_id=issue_id,
actor=actor,
verb="updated",
old_value=f"{project.identifier}-{old_parent.sequence_id}",
new_value=None,
field="parent",
project=project,
workspace=project.workspace,
comment=f"{actor.email} updated the parent issue to None",
old_identifier=old_parent.id,
new_identifier=None,
)
)
else:
new_parent = Issue.objects.get(pk=requested_data.get("parent"))
old_parent = Issue.objects.filter(
pk=current_instance.get("parent")
).first()
issue_activities.append(
IssueActivity(
issue_id=issue_id,
actor=actor,
verb="updated",
old_value=f"{project.identifier}-{old_parent.sequence_id}"
if old_parent is not None
else None,
new_value=f"{project.identifier}-{new_parent.sequence_id}",
field="parent",
project=project,
workspace=project.workspace,
comment=f"{actor.email} updated the parent issue to {new_parent.name}",
old_identifier=old_parent.id
if old_parent is not None
else None,
new_identifier=new_parent.id,
)
)
# Track changes in priority
def track_priority(
self,
requested_data,
current_instance,
issue_id,
project,
actor,
issue_activities,
):
if current_instance.get("priority") != requested_data.get("priority"):
if requested_data.get("priority") == None:
issue_activities.append(
IssueActivity(
issue_id=issue_id,
actor=actor,
verb="updated",
old_value=current_instance.get("parent"),
new_value=requested_data.get("parent"),
field="priority",
project=project,
workspace=project.workspace,
comment=f"{actor.email} updated the priority to None",
)
)
else:
issue_activities.append(
IssueActivity(
issue_id=issue_id,
actor=actor,
verb="updated",
old_value=current_instance.get("priority"),
new_value=requested_data.get("priority"),
field="priority",
project=project,
workspace=project.workspace,
comment=f"{actor.email} updated the priority to {requested_data.get('priority')}",
)
)
# Track chnages in state of the issue
def track_state(
self,
requested_data,
current_instance,
issue_id,
project,
actor,
issue_activities,
):
if current_instance.get("state") != requested_data.get("state"):
new_state = State.objects.get(pk=requested_data.get("state", None))
old_state = State.objects.get(pk=current_instance.get("state", None))
issue_activities.append(
IssueActivity(
issue_id=issue_id,
actor=actor,
verb="updated",
old_value=old_state.name,
new_value=new_state.name,
field="state",
project=project,
workspace=project.workspace,
comment=f"{actor.email} updated the state to {new_state.name}",
old_identifier=old_state.id,
new_identifier=new_state.id,
)
)
# Track issue description
def track_description(
self,
requested_data,
current_instance,
issue_id,
project,
actor,
issue_activities,
):
if current_instance.get("description_html") != requested_data.get("description_html"):
issue_activities.append(
IssueActivity(
issue_id=issue_id,
actor=actor,
verb="updated",
old_value=current_instance.get("description_html"),
new_value=requested_data.get("description_html"),
field="description",
project=project,
workspace=project.workspace,
comment=f"{actor.email} updated the description to {requested_data.get('description_html')}",
)
)
# Track changes in issue target date
def track_target_date(
self,
requested_data,
current_instance,
issue_id,
project,
actor,
issue_activities,
):
if current_instance.get("target_date") != requested_data.get("target_date"):
if requested_data.get("target_date") == None:
issue_activities.append(
IssueActivity(
issue_id=issue_id,
actor=actor,
verb="updated",
old_value=current_instance.get("target_date"),
new_value=requested_data.get("target_date"),
field="target_date",
project=project,
workspace=project.workspace,
comment=f"{actor.email} updated the target date to None",
)
)
else:
issue_activities.append(
IssueActivity(
issue_id=issue_id,
actor=actor,
verb="updated",
old_value=current_instance.get("target_date"),
new_value=requested_data.get("target_date"),
field="target_date",
project=project,
workspace=project.workspace,
comment=f"{actor.email} updated the target date to {requested_data.get('target_date')}",
)
)
# Track changes in issue start date
def track_start_date(
self,
requested_data,
current_instance,
issue_id,
project,
actor,
issue_activities,
):
if current_instance.get("start_date") != requested_data.get("start_date"):
if requested_data.get("start_date") == None:
issue_activities.append(
IssueActivity(
issue_id=issue_id,
actor=actor,
verb="updated",
old_value=current_instance.get("start_date"),
new_value=requested_data.get("start_date"),
field="start_date",
project=project,
workspace=project.workspace,
comment=f"{actor.email} updated the start date to None",
)
)
else:
issue_activities.append(
IssueActivity(
issue_id=issue_id,
actor=actor,
verb="updated",
old_value=current_instance.get("start_date"),
new_value=requested_data.get("start_date"),
field="start_date",
project=project,
workspace=project.workspace,
comment=f"{actor.email} updated the start date to {requested_data.get('start_date')}",
)
)
# Track changes in issue labels
def track_labels(
self,
requested_data,
current_instance,
issue_id,
project,
actor,
issue_activities,
):
# Label Addition
if len(requested_data.get("labels_list")) > len(current_instance.get("labels")):
for label in requested_data.get("labels_list"):
if label not in current_instance.get("labels"):
label = Label.objects.get(pk=label)
issue_activities.append(
IssueActivity(
issue_id=issue_id,
actor=actor,
verb="updated",
old_value="",
new_value=label.name,
field="labels",
project=project,
workspace=project.workspace,
comment=f"{actor.email} added label {label.name}",
new_identifier=label.id,
old_identifier=None,
)
)
# Label Removal
if len(requested_data.get("labels_list")) < len(current_instance.get("labels")):
for label in current_instance.get("labels"):
if label not in requested_data.get("labels_list"):
label = Label.objects.get(pk=label)
issue_activities.append(
IssueActivity(
issue_id=issue_id,
actor=actor,
verb="updated",
old_value=label.name,
new_value="",
field="labels",
project=project,
workspace=project.workspace,
comment=f"{actor.email} removed label {label.name}",
old_identifier=label.id,
new_identifier=None,
)
)
# Track changes in issue assignees
def track_assignees(
self,
requested_data,
current_instance,
issue_id,
project,
actor,
issue_activities,
):
# Assignee Addition
if len(requested_data.get("assignees_list")) > len(
current_instance.get("assignees")
):
for assignee in requested_data.get("assignees_list"):
if assignee not in current_instance.get("assignees"):
assignee = User.objects.get(pk=assignee)
issue_activities.append(
IssueActivity(
issue_id=issue_id,
actor=actor,
verb="updated",
old_value="",
new_value=assignee.email,
field="assignees",
project=project,
workspace=project.workspace,
comment=f"{actor.email} added assignee {assignee.email}",
new_identifier=actor.id,
)
)
# Assignee Removal
if len(requested_data.get("assignees_list")) < len(
current_instance.get("assignees")
):
for assignee in current_instance.get("assignees"):
if assignee not in requested_data.get("assignees_list"):
assignee = User.objects.get(pk=assignee)
issue_activities.append(
IssueActivity(
issue_id=issue_id,
actor=actor,
verb="updated",
old_value=assignee.email,
new_value="",
field="assignee",
project=project,
workspace=project.workspace,
comment=f"{actor.email} removed assignee {assignee.email}",
old_identifier=actor.id,
)
)
# Track changes in blocking issues
def track_blocks(
self,
requested_data,
current_instance,
issue_id,
project,
actor,
issue_activities,
):
if len(requested_data.get("blocks_list")) > len(
current_instance.get("blocked_issues")
):
for block in requested_data.get("blocks_list"):
if (
len(
[
blocked
for blocked in current_instance.get("blocked_issues")
if blocked.get("block") == block
]
)
== 0
):
issue = Issue.objects.get(pk=block)
issue_activities.append(
IssueActivity(
issue_id=issue_id,
actor=actor,
verb="updated",
old_value="",
new_value=f"{project.identifier}-{issue.sequence_id}",
field="blocks",
project=project,
workspace=project.workspace,
comment=f"{actor.email} added blocking issue {project.identifier}-{issue.sequence_id}",
new_identifier=issue.id,
)
)
# Blocked Issue Removal
if len(requested_data.get("blocks_list")) < len(
current_instance.get("blocked_issues")
):
for blocked in current_instance.get("blocked_issues"):
if blocked.get("block") not in requested_data.get("blocks_list"):
issue = Issue.objects.get(pk=blocked.get("block"))
issue_activities.append(
IssueActivity(
issue_id=issue_id,
actor=actor,
verb="updated",
old_value=f"{project.identifier}-{issue.sequence_id}",
new_value="",
field="blocks",
project=project,
workspace=project.workspace,
comment=f"{actor.email} removed blocking issue {project.identifier}-{issue.sequence_id}",
old_identifier=issue.id,
)
)
# Track changes in blocked_by issues
def track_blockings(
self,
requested_data,
current_instance,
issue_id,
project,
actor,
issue_activities,
):
if len(requested_data.get("blockers_list")) > len(
current_instance.get("blocker_issues")
):
for block in requested_data.get("blockers_list"):
if (
len(
[
blocked
for blocked in current_instance.get("blocker_issues")
if blocked.get("blocked_by") == block
]
)
== 0
):
issue = Issue.objects.get(pk=block)
issue_activities.append(
IssueActivity(
issue_id=issue_id,
actor=actor,
verb="updated",
old_value="",
new_value=f"{project.identifier}-{issue.sequence_id}",
field="blocking",
project=project,
workspace=project.workspace,
comment=f"{actor.email} added blocked by issue {project.identifier}-{issue.sequence_id}",
new_identifier=issue.id,
)
)
# Blocked Issue Removal
if len(requested_data.get("blockers_list")) < len(
current_instance.get("blocker_issues")
):
for blocked in current_instance.get("blocker_issues"):
if blocked.get("blocked_by") not in requested_data.get("blockers_list"):
issue = Issue.objects.get(pk=blocked.get("blocked_by"))
issue_activities.append(
IssueActivity(
issue_id=issue_id,
actor=actor,
verb="updated",
old_value=f"{project.identifier}-{issue.sequence_id}",
new_value="",
field="blocking",
project=project,
workspace=project.workspace,
comment=f"{actor.email} removed blocked by issue {project.identifier}-{issue.sequence_id}",
old_identifier=issue.id,
)
)
# Receive message from room group
def issue_activity(self, event):
issue_activities = []
# Remove event type:
event.pop("type")
requested_data = json.loads(event.get("requested_data"))
current_instance = json.loads(event.get("current_instance"))
issue_id = event.get("issue_id")
actor_id = event.get("actor_id")
project_id = event.get("project_id")
actor = User.objects.get(pk=actor_id)
project = Project.objects.get(pk=project_id)
ISSUE_ACTIVITY_MAPPER = {
"name": self.track_name,
"parent": self.track_parent,
"priority": self.track_priority,
"state": self.track_state,
"description": self.track_description,
"target_date": self.track_target_date,
"start_date": self.track_start_date,
"labels_list": self.track_labels,
"assignees_list": self.track_assignees,
"blocks_list": self.track_blocks,
"blockers_list": self.track_blockings,
}
for key in requested_data:
func = ISSUE_ACTIVITY_MAPPER.get(key, None)
if func is not None:
func(
requested_data,
current_instance,
issue_id,
project,
actor,
issue_activities,
)
# Save all the values to database
IssueActivity.objects.bulk_create(issue_activities)

View File

@ -47,6 +47,7 @@ class IssueFlatSerializer(BaseSerializer):
class IssueStateSerializer(BaseSerializer):
state_detail = StateSerializer(read_only=True, source="state")
project_detail = ProjectSerializer(read_only=True, source="project")
class Meta:
model = Issue
@ -67,6 +68,8 @@ class IssueCreateSerializer(BaseSerializer):
write_only=True,
required=False,
)
# List of issues that are blocking this issue
blockers_list = serializers.ListField(
child=serializers.PrimaryKeyRelatedField(queryset=Issue.objects.all()),
write_only=True,
@ -77,6 +80,8 @@ class IssueCreateSerializer(BaseSerializer):
write_only=True,
required=False,
)
# List of issues that are blocked by this issue
blocks_list = serializers.ListField(
child=serializers.PrimaryKeyRelatedField(queryset=Issue.objects.all()),
write_only=True,
@ -421,10 +426,12 @@ class IssueSerializer(BaseSerializer):
parent_detail = IssueFlatSerializer(read_only=True, source="parent")
label_details = LabelSerializer(read_only=True, source="labels", many=True)
assignee_details = UserLiteSerializer(read_only=True, source="assignees", many=True)
# List of issues blocked by this issue
blocked_issues = BlockedIssueSerializer(read_only=True, many=True)
# List of issues that block this issue
blocker_issues = BlockerIssueSerializer(read_only=True, many=True)
issue_cycle = IssueCycleDetailSerializer(read_only=True)
issue_module = IssueModuleDetailSerializer(read_only=True, many=True)
issue_module = IssueModuleDetailSerializer(read_only=True)
class Meta:
model = Issue

View File

@ -7,7 +7,13 @@ from .user import UserLiteSerializer
from .project import ProjectSerializer
from .issue import IssueStateSerializer
from plane.db.models import User, Module, ModuleMember, ModuleIssue
from plane.db.models import User, Module, ModuleMember, ModuleIssue, ModuleLink
class LinkCreateSerializer(serializers.Serializer):
url = serializers.CharField(required=True)
title = serializers.CharField(required=False)
class ModuleWriteSerializer(BaseSerializer):
@ -17,6 +23,11 @@ class ModuleWriteSerializer(BaseSerializer):
write_only=True,
required=False,
)
links_list = serializers.ListField(
child=LinkCreateSerializer(),
write_only=True,
required=False,
)
class Meta:
model = Module
@ -33,6 +44,7 @@ class ModuleWriteSerializer(BaseSerializer):
def create(self, validated_data):
members = validated_data.pop("members_list", None)
links = validated_data.pop("links_list", None)
project = self.context["project"]
@ -55,11 +67,31 @@ class ModuleWriteSerializer(BaseSerializer):
ignore_conflicts=True,
)
if links is not None:
ModuleLink.objects.bulk_create(
[
ModuleLink(
module=module,
project=project,
workspace=project.workspace,
created_by=module.created_by,
updated_by=module.updated_by,
title=link.get("title", None),
url=link.get("url", None),
)
for link in links
],
batch_size=10,
ignore_conflicts=True,
)
return module
def update(self, instance, validated_data):
members = validated_data.pop("members_list", None)
links = validated_data.pop("links_list", None)
if members is not None:
ModuleIssue.objects.filter(module=instance).delete()
ModuleMember.objects.bulk_create(
@ -75,7 +107,26 @@ class ModuleWriteSerializer(BaseSerializer):
for member in members
],
batch_size=10,
ignore_conflicts=True
ignore_conflicts=True,
)
if links is not None:
ModuleLink.objects.filter(module=instance).delete()
ModuleLink.objects.bulk_create(
[
ModuleLink(
module=instance,
project=instance.project,
workspace=instance.project.workspace,
created_by=instance.created_by,
updated_by=instance.updated_by,
title=link.get("title", None),
url=link.get("url", None),
)
for link in links
],
batch_size=10,
ignore_conflicts=True,
)
return super().update(instance, validated_data)
@ -114,12 +165,30 @@ class ModuleIssueSerializer(BaseSerializer):
]
class ModuleLinkSerializer(BaseSerializer):
created_by_detail = UserLiteSerializer(read_only=True, source="created_by")
class Meta:
model = ModuleLink
fields = "__all__"
read_only_fields = [
"workspace",
"project",
"created_by",
"updated_by",
"created_at",
"updated_at",
]
class ModuleSerializer(BaseSerializer):
project_detail = ProjectSerializer(read_only=True, source="project")
lead_detail = UserLiteSerializer(read_only=True, source="lead")
members_detail = UserLiteSerializer(read_only=True, many=True, source="members")
module_issues = ModuleIssueSerializer(read_only=True, many=True)
issue_module = ModuleIssueSerializer(read_only=True, many=True)
link_module = ModuleLinkSerializer(read_only=True, many=True)
class Meta:
model = Module
@ -131,4 +200,4 @@ class ModuleSerializer(BaseSerializer):
"updated_by",
"created_at",
"updated_at",
]
]

View File

@ -18,18 +18,12 @@ class WorkSpaceSerializer(BaseSerializer):
fields = "__all__"
read_only_fields = [
"id",
"slug",
"created_by",
"updated_by",
"created_at",
"updated_at",
"owner",
]
extra_kwargs = {
"slug": {
"required": False,
},
}
class WorkSpaceMemberSerializer(BaseSerializer):

View File

@ -58,6 +58,9 @@ from plane.api.views import (
UserLastProjectWithWorkspaceEndpoint,
UserWorkSpaceIssues,
ProjectMemberUserEndpoint,
WorkspaceMemberUserEndpoint,
WorkspaceMemberUserViewsEndpoint,
WorkSpaceAvailabilityCheckEndpoint,
)
from plane.api.views.project import AddTeamToProjectEndpoint
@ -147,6 +150,11 @@ urlpatterns = [
name="user-project-invitaions",
),
## Workspaces ##
path(
"workspace-slug-check/",
WorkSpaceAvailabilityCheckEndpoint.as_view(),
name="workspace-availability",
),
path(
"workspaces/",
WorkSpaceViewSet.as_view(
@ -234,6 +242,16 @@ urlpatterns = [
UserLastProjectWithWorkspaceEndpoint.as_view(),
name="workspace-project-details",
),
path(
"workspaces/<str:slug>/workspace-members/me/",
WorkspaceMemberUserEndpoint.as_view(),
name="workspace-member-details",
),
path(
"workspaces/<str:slug>/workspace-views/",
WorkspaceMemberUserViewsEndpoint.as_view(),
name="workspace-member-details",
),
## End Workspaces ##
# Projects
path(
@ -585,7 +603,7 @@ urlpatterns = [
## IssueProperty Ebd
## File Assets
path(
"file-assets/",
"workspaces/<str:slug>/file-assets/",
FileAssetEndpoint.as_view(),
name="File Assets",
),

View File

@ -34,6 +34,8 @@ from .workspace import (
UserWorkspaceInvitationsEndpoint,
UserWorkspaceInvitationEndpoint,
UserLastProjectWithWorkspaceEndpoint,
WorkspaceMemberUserEndpoint,
WorkspaceMemberUserViewsEndpoint,
)
from .state import StateViewSet
from .shortcut import ShortCutViewSet

View File

@ -2,10 +2,11 @@
from rest_framework import status
from rest_framework.response import Response
from rest_framework.parsers import MultiPartParser, FormParser
from sentry_sdk import capture_exception
# Module imports
from .base import BaseAPIView
from plane.db.models import FileAsset
from plane.db.models import FileAsset, Workspace
from plane.api.serializers import FileAssetSerializer
@ -22,9 +23,23 @@ class FileAssetEndpoint(BaseAPIView):
serializer = FileAssetSerializer(files, context={"request": request}, many=True)
return Response(serializer.data)
def post(self, request, *args, **kwargs):
serializer = FileAssetSerializer(data=request.data)
if serializer.is_valid():
serializer.save()
return Response(serializer.data, status=status.HTTP_201_CREATED)
return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST)
def post(self, request, slug):
try:
serializer = FileAssetSerializer(data=request.data)
if serializer.is_valid():
if request.user.last_workspace_id is None:
return Response(
{"error": "Workspace id is required"},
status=status.HTTP_400_BAD_REQUEST,
)
serializer.save(workspace_id=request.user.last_workspace_id)
return Response(serializer.data, status=status.HTTP_201_CREATED)
return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST)
except Exception as e:
capture_exception(e)
return Response(
{"error": "Something went wrong please try again later"},
status=status.HTTP_400_BAD_REQUEST,
)

View File

@ -155,5 +155,5 @@ class ChangePasswordEndpoint(BaseAPIView):
capture_exception(e)
return Response(
{"error": "Something went wrong please try again later"},
status=status.HTTP_500_INTERNAL_SERVER_ERROR,
status=status.HTTP_400_BAD_REQUEST,
)

View File

@ -288,7 +288,7 @@ class MagicSignInGenerateEndpoint(BaseAPIView):
capture_exception(e)
return Response(
{"error": "Something went wrong please try again later"},
status=status.HTTP_500_INTERNAL_SERVER_ERROR,
status=status.HTTP_400_BAD_REQUEST,
)
@ -361,5 +361,5 @@ class MagicSignInEndpoint(BaseAPIView):
capture_exception(e)
return Response(
{"error": "Something went wrong please try again later"},
status=status.HTTP_500_INTERNAL_SERVER_ERROR,
status=status.HTTP_400_BAD_REQUEST,
)

View File

@ -40,7 +40,7 @@ class BaseViewSet(ModelViewSet, BasePaginator):
except Exception as e:
print(e)
raise APIException(
"Please check the view", status.HTTP_500_INTERNAL_SERVER_ERROR
"Please check the view", status.HTTP_400_BAD_REQUEST
)
def dispatch(self, request, *args, **kwargs):

View File

@ -113,5 +113,5 @@ class CycleIssueViewSet(BaseViewSet):
capture_exception(e)
return Response(
{"error": "Something went wrong please try again later"},
status=status.HTTP_500_INTERNAL_SERVER_ERROR,
status=status.HTTP_400_BAD_REQUEST,
)

View File

@ -1,14 +1,18 @@
# Python imports
from itertools import groupby
import json
from itertools import groupby, chain
# Django imports
from django.db.models import Prefetch
from django.db.models import Count, Sum
from django.core.serializers.json import DjangoJSONEncoder
# Third Party imports
from rest_framework.response import Response
from rest_framework import status
from sentry_sdk import capture_exception
from channels.layers import get_channel_layer
from asgiref.sync import async_to_sync
# Module imports
from . import BaseViewSet, BaseAPIView
@ -37,7 +41,7 @@ from plane.db.models import (
Label,
IssueBlocker,
CycleIssue,
ModuleIssue
ModuleIssue,
)
@ -67,6 +71,28 @@ class IssueViewSet(BaseViewSet):
def perform_create(self, serializer):
serializer.save(project_id=self.kwargs.get("project_id"))
def perform_update(self, serializer):
requested_data = json.dumps(self.request.data, cls=DjangoJSONEncoder)
current_instance = Issue.objects.filter(pk=self.kwargs.get("pk", None)).first()
if current_instance is not None:
channel_layer = get_channel_layer()
async_to_sync(channel_layer.send)(
"issue-activites",
{
"type": "issue.activity",
"requested_data": requested_data,
"actor_id": str(self.request.user.id),
"issue_id": str(self.kwargs.get("pk", None)),
"project_id": str(self.kwargs.get("project_id", None)),
"current_instance": json.dumps(
IssueSerializer(current_instance).data, cls=DjangoJSONEncoder
),
},
)
return super().perform_update(serializer)
def get_queryset(self):
return (
super()
@ -146,7 +172,7 @@ class IssueViewSet(BaseViewSet):
capture_exception(e)
return Response(
{"error": "Something went wrong please try again later"},
status=status.HTTP_500_INTERNAL_SERVER_ERROR,
status=status.HTTP_400_BAD_REQUEST,
)
def create(self, request, slug, project_id):
@ -158,6 +184,17 @@ class IssueViewSet(BaseViewSet):
if serializer.is_valid():
serializer.save()
# Track the issue
IssueActivity.objects.create(
issue_id=serializer.data["id"],
project_id=project_id,
workspace_id=serializer["workspace"],
comment=f"{request.user.email} created the issue",
verb="created",
actor=request.user,
)
return Response(serializer.data, status=status.HTTP_201_CREATED)
return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST)
@ -179,7 +216,7 @@ class UserWorkSpaceIssues(BaseAPIView):
capture_exception(e)
return Response(
{"error": "Something went wrong please try again later"},
status=status.HTTP_500_INTERNAL_SERVER_ERROR,
status=status.HTTP_400_BAD_REQUEST,
)
@ -200,23 +237,42 @@ class WorkSpaceIssuesEndpoint(BaseAPIView):
capture_exception(e)
return Response(
{"error": "Something went wrong please try again later"},
status=status.HTTP_500_INTERNAL_SERVER_ERROR,
status=status.HTTP_400_BAD_REQUEST,
)
class IssueActivityEndpoint(BaseAPIView):
permission_classes = [
ProjectEntityPermission,
]
def get(self, request, slug, project_id, issue_id):
try:
issue_activities = IssueActivity.objects.filter(issue_id=issue_id).filter(
project__project_projectmember__member=self.request.user
issue_activities = (
IssueActivity.objects.filter(issue_id=issue_id)
.filter(project__project_projectmember__member=self.request.user)
.select_related("actor")
).order_by("created_by")
issue_comments = (
IssueComment.objects.filter(issue_id=issue_id)
.filter(project__project_projectmember__member=self.request.user)
.order_by("created_at")
)
serializer = IssueActivitySerializer(issue_activities, many=True)
return Response(serializer.data, status=status.HTTP_200_OK)
issue_activities = IssueActivitySerializer(issue_activities, many=True).data
issue_comments = IssueCommentSerializer(issue_comments, many=True).data
result_list = sorted(
chain(issue_activities, issue_comments),
key=lambda instance: instance["created_at"],
)
return Response(result_list, status=status.HTTP_200_OK)
except Exception as e:
capture_exception(e)
return Response(
{"error": "Something went wrong please try again later"},
status=status.HTTP_500_INTERNAL_SERVER_ERROR,
status=status.HTTP_400_BAD_REQUEST,
)
@ -224,6 +280,9 @@ class IssueCommentViewSet(BaseViewSet):
serializer_class = IssueCommentSerializer
model = IssueComment
permission_classes = [
ProjectEntityPermission,
]
filterset_fields = [
"issue__id",
@ -343,7 +402,7 @@ class IssuePropertyViewSet(BaseViewSet):
capture_exception(e)
return Response(
{"error": "Something went wrong please try again later"},
status=status.HTTP_500_INTERNAL_SERVER_ERROR,
status=status.HTTP_400_BAD_REQUEST,
)
@ -407,5 +466,5 @@ class BulkDeleteIssuesEndpoint(BaseAPIView):
capture_exception(e)
return Response(
{"error": "Something went wrong please try again later"},
status=status.HTTP_500_INTERNAL_SERVER_ERROR,
status=status.HTTP_400_BAD_REQUEST,
)

View File

@ -15,7 +15,7 @@ from plane.api.serializers import (
ModuleIssueSerializer,
)
from plane.api.permissions import ProjectEntityPermission
from plane.db.models import Module, ModuleIssue, Project, Issue
from plane.db.models import Module, ModuleIssue, Project, Issue, ModuleLink
class ModuleViewSet(BaseViewSet):
@ -48,6 +48,12 @@ class ModuleViewSet(BaseViewSet):
queryset=ModuleIssue.objects.select_related("module", "issue"),
)
)
.prefetch_related(
Prefetch(
"link_module",
queryset=ModuleLink.objects.select_related("module"),
)
)
)
def create(self, request, slug, project_id):
@ -76,7 +82,7 @@ class ModuleViewSet(BaseViewSet):
capture_exception(e)
return Response(
{"error": "Something went wrong please try again later"},
status=status.HTTP_500_INTERNAL_SERVER_ERROR,
status=status.HTTP_400_BAD_REQUEST,
)
@ -130,6 +136,9 @@ class ModuleIssueViewSet(BaseViewSet):
pk__in=issues, workspace__slug=slug, project_id=project_id
)
# Delete old records in order to maintain the database integrity
ModuleIssue.objects.filter(issue_id__in=issues).delete()
ModuleIssue.objects.bulk_create(
[
ModuleIssue(
@ -154,5 +163,5 @@ class ModuleIssueViewSet(BaseViewSet):
capture_exception(e)
return Response(
{"error": "Something went wrong please try again later"},
status=status.HTTP_500_INTERNAL_SERVER_ERROR,
)
status=status.HTTP_400_BAD_REQUEST,
)

View File

@ -7,11 +7,11 @@ from sentry_sdk import capture_exception
# Module imports
from plane.api.serializers import (
UserSerializer,
WorkSpaceSerializer,
)
from plane.api.views.base import BaseViewSet, BaseAPIView
from plane.db.models import User
from plane.db.models import User, Workspace
class PeopleEndpoint(BaseAPIView):
@ -44,8 +44,8 @@ class PeopleEndpoint(BaseAPIView):
except Exception as e:
capture_exception(e)
return Response(
{"message": "Something went wrong"},
status=status.HTTP_500_INTERNAL_SERVER_ERROR,
{"message": "Something went wrong please try again later"},
status=status.HTTP_400_BAD_REQUEST,
)
@ -56,6 +56,19 @@ class UserEndpoint(BaseViewSet):
def get_object(self):
return self.request.user
def retrieve(self, request):
try:
workspace = Workspace.objects.get(pk=request.user.last_workspace_id)
return Response(
{"user": UserSerializer(request.user).data, "slug": workspace.slug}
)
except Workspace.DoesNotExist:
return Response({"user": UserSerializer(request.user).data, "slug": None})
except Exception as e:
return Response(
{"error": "Something went wrong please try again later"},
status=status.HTTP_400_BAD_REQUEST,
)
class UpdateUserOnBoardedEndpoint(BaseAPIView):

View File

@ -143,7 +143,7 @@ class ProjectViewSet(BaseViewSet):
except IntegrityError as e:
if "already exists" in str(e):
return Response(
{"identifier": "The project identifier is already taken"},
{"name": "The project name is already taken"},
status=status.HTTP_410_GONE,
)
except Workspace.DoesNotExist as e:
@ -159,7 +159,7 @@ class ProjectViewSet(BaseViewSet):
capture_exception(e)
return Response(
{"error": "Something went wrong please try again later"},
status=status.HTTP_500_INTERNAL_SERVER_ERROR,
status=status.HTTP_400_BAD_REQUEST,
)
def partial_update(self, request, slug, pk=None):
@ -199,7 +199,7 @@ class ProjectViewSet(BaseViewSet):
capture_exception(e)
return Response(
{"error": "Something went wrong please try again later"},
status=status.HTTP_500_INTERNAL_SERVER_ERROR,
status=status.HTTP_400_BAD_REQUEST,
)
@ -280,7 +280,7 @@ class InviteProjectEndpoint(BaseAPIView):
capture_exception(e)
return Response(
{"error": "Something went wrong please try again later"},
status=status.HTTP_500_INTERNAL_SERVER_ERROR,
status=status.HTTP_400_BAD_REQUEST,
)
@ -324,7 +324,7 @@ class UserProjectInvitationsViewset(BaseViewSet):
capture_exception(e)
return Response(
{"error": "Something went wrong please try again later"},
status=status.HTTP_500_INTERNAL_SERVER_ERROR,
status=status.HTTP_400_BAD_REQUEST,
)
@ -353,6 +353,11 @@ class ProjectMemberViewSet(BaseViewSet):
class AddMemberToProjectEndpoint(BaseAPIView):
permission_classes = [
ProjectBasePermission,
]
def post(self, request, slug, project_id):
try:
@ -399,11 +404,16 @@ class AddMemberToProjectEndpoint(BaseAPIView):
capture_exception(e)
return Response(
{"error": "Something went wrong please try again later"},
status=status.HTTP_500_INTERNAL_SERVER_ERROR,
status=status.HTTP_400_BAD_REQUEST,
)
class AddTeamToProjectEndpoint(BaseAPIView):
permission_classes = [
ProjectBasePermission,
]
def post(self, request, slug, project_id):
try:
@ -449,7 +459,7 @@ class AddTeamToProjectEndpoint(BaseAPIView):
capture_exception(e)
return Response(
{"error": "Something went wrong please try again later"},
status=status.HTTP_500_INTERNAL_SERVER_ERROR,
status=status.HTTP_400_BAD_REQUEST,
)
@ -517,7 +527,7 @@ class ProjectIdentifierEndpoint(BaseAPIView):
capture_exception(e)
return Response(
{"error": "Something went wrong please try again later"},
status=status.HTTP_500_INTERNAL_SERVER_ERROR,
status=status.HTTP_400_BAD_REQUEST,
)
def delete(self, request, slug):
@ -545,7 +555,7 @@ class ProjectIdentifierEndpoint(BaseAPIView):
capture_exception(e)
return Response(
{"error": "Something went wrong please try again later"},
status=status.HTTP_500_INTERNAL_SERVER_ERROR,
status=status.HTTP_400_BAD_REQUEST,
)
@ -590,7 +600,7 @@ class ProjectJoinEndpoint(BaseAPIView):
capture_exception(e)
return Response(
{"error": "Something went wrong please try again later"},
status=status.HTTP_500_INTERNAL_SERVER_ERROR,
status=status.HTTP_400_BAD_REQUEST,
)
@ -629,7 +639,7 @@ class ProjectUserViewsEndpoint(BaseAPIView):
except Exception as e:
return Response(
{"error": "Something went wrong please try again later"},
status=status.HTTP_500_INTERNAL_SERVER_ERROR,
status=status.HTTP_400_BAD_REQUEST,
)
@ -647,11 +657,11 @@ class ProjectMemberUserEndpoint(BaseAPIView):
except ProjectMember.DoesNotExist:
return Response(
{"error": "User not a member of the project"},
status=status.HTTP_404_NOT_FOUND,
status=status.HTTP_403_FORBIDDEN,
)
except Exception as e:
capture_exception(e)
return Response(
{"error": "Something went wrong please try again later"},
status=status.HTTP_500_INTERNAL_SERVER_ERROR,
status=status.HTTP_400_BAD_REQUEST,
)

View File

@ -86,7 +86,7 @@ class WorkSpaceViewSet(BaseViewSet):
except IntegrityError as e:
if "already exists" in str(e):
return Response(
{"name": "The workspace with the name already exists"},
{"slug": "The workspace with the slug already exists"},
status=status.HTTP_410_GONE,
)
except Exception as e:
@ -96,7 +96,7 @@ class WorkSpaceViewSet(BaseViewSet):
"error": "Something went wrong please try again later",
"identifier": None,
},
status=status.HTTP_500_INTERNAL_SERVER_ERROR,
status=status.HTTP_400_BAD_REQUEST,
)
@ -128,34 +128,28 @@ class UserWorkSpacesEndpoint(BaseAPIView):
capture_exception(e)
return Response(
{"error": "Something went wrong please try again later"},
status=status.HTTP_500_INTERNAL_SERVER_ERROR,
status=status.HTTP_400_BAD_REQUEST,
)
class WorkSpaceAvailabilityCheckEndpoint(BaseAPIView):
permission_classes = [
AllowAny,
]
def get(self, request):
try:
name = request.GET.get("name", False)
slug = request.GET.get("slug", False)
if not name:
if not slug or slug == "":
return Response(
{"error": "Workspace Name is required"},
{"error": "Workspace Slug is required"},
status=status.HTTP_400_BAD_REQUEST,
)
workspace = Workspace.objects.filter(name=name).exists()
return Response({"status": workspace}, status=status.HTTP_200_OK)
workspace = Workspace.objects.filter(slug=slug).exists()
return Response({"status": not workspace}, status=status.HTTP_200_OK)
except Exception as e:
capture_exception(e)
return Response(
{"error": "Something went wrong please try again later"},
status=status.HTTP_500_INTERNAL_SERVER_ERROR,
status=status.HTTP_400_BAD_REQUEST,
)
@ -168,70 +162,92 @@ class InviteWorkspaceEndpoint(BaseAPIView):
def post(self, request, slug):
try:
email = request.data.get("email", False)
emails = request.data.get("emails", False)
# Check if email is provided
if not email:
if not emails or not len(emails):
return Response(
{"error": "Email is required"}, status=status.HTTP_400_BAD_REQUEST
{"error": "Emails are required"}, status=status.HTTP_400_BAD_REQUEST
)
validate_email(email)
# Check if user is already a member of workspace
workspace = Workspace.objects.get(slug=slug)
if WorkspaceMember.objects.filter(
workspace_id=workspace.id, member__email=email
).exists():
# Check if user is already a member of workspace
workspace_members = WorkspaceMember.objects.filter(
workspace_id=workspace.id,
member__email__in=[email.get("email") for email in emails],
)
if len(workspace_members):
return Response(
{"error": "User is already member of workspace"},
{
"error": "Some users are already member of workspace",
"workspace_users": WorkSpaceMemberSerializer(
workspace_members, many=True
).data,
},
status=status.HTTP_400_BAD_REQUEST,
)
token = jwt.encode(
{"email": email, "timestamp": datetime.now().timestamp()},
settings.SECRET_KEY,
algorithm="HS256",
workspace_invitations = []
for email in emails:
try:
validate_email(email.get("email"))
workspace_invitations.append(
WorkspaceMemberInvite(
email=email.get("email").strip().lower(),
workspace_id=workspace.id,
token=jwt.encode(
{
"email": email,
"timestamp": datetime.now().timestamp(),
},
settings.SECRET_KEY,
algorithm="HS256",
),
role=email.get("role", 10),
)
)
except ValidationError:
return Response(
{
"error": f"Invalid email - {email} provided a valid email address is required to send the invite"
},
status=status.HTTP_400_BAD_REQUEST,
)
WorkspaceMemberInvite.objects.bulk_create(
workspace_invitations, batch_size=10, ignore_conflicts=True
)
workspace_invitation_obj = WorkspaceMemberInvite.objects.create(
email=email.strip().lower(),
workspace_id=workspace.id,
token=token,
role=request.data.get("role", 10),
)
workspace_invitations = WorkspaceMemberInvite.objects.filter(
email__in=[email.get("email") for email in emails]
).select_related("workspace")
domain = settings.WEB_URL
workspace_invitation.delay(
email, workspace.id, token, domain, request.user.email
)
for invitation in workspace_invitations:
workspace_invitation.delay(
invitation.email,
workspace.id,
invitation.token,
settings.WEB_URL,
request.user.email,
)
return Response(
{
"message": "Email sent successfully",
"id": workspace_invitation_obj.id,
"message": "Emails sent successfully",
},
status=status.HTTP_200_OK,
)
except ValidationError:
return Response(
{
"error": "Invalid email address provided a valid email address is required to send the invite"
},
status=status.HTTP_400_BAD_REQUEST,
)
except Workspace.DoesNotExist:
return Response(
{"error": "Workspace does not exists"},
status=status.HTTP_400_BAD_REQUEST,
)
except Exception as e:
print(e)
capture_exception(e)
return Response(
{"error": "Something went wrong please try again later"},
status=status.HTTP_500_INTERNAL_SERVER_ERROR,
status=status.HTTP_400_BAD_REQUEST,
)
@ -261,6 +277,24 @@ class JoinWorkspaceEndpoint(BaseAPIView):
workspace_invite.save()
if workspace_invite.accepted:
# Check if the user created account after invitation
user = User.objects.filter(email=email).first()
# If the user is present then create the workspace member
if user is not None:
WorkspaceMember.objects.create(
workspace=workspace_invite.workspace,
member=user,
role=workspace_invite.role,
)
user.last_workspace_id = workspace_invite.workspace.id
user.save()
# Delete the invitation
workspace_invite.delete()
return Response(
{"message": "Workspace Invitation Accepted"},
status=status.HTTP_200_OK,
@ -286,7 +320,7 @@ class JoinWorkspaceEndpoint(BaseAPIView):
capture_exception(e)
return Response(
{"error": "Something went wrong please try again later"},
status=status.HTTP_500_INTERNAL_SERVER_ERROR,
status=status.HTTP_400_BAD_REQUEST,
)
@ -349,7 +383,7 @@ class UserWorkspaceInvitationsEndpoint(BaseViewSet):
capture_exception(e)
return Response(
{"error": "Something went wrong please try again later"},
status=status.HTTP_500_INTERNAL_SERVER_ERROR,
status=status.HTTP_400_BAD_REQUEST,
)
@ -381,6 +415,9 @@ class TeamMemberViewSet(BaseViewSet):
serializer_class = TeamSerializer
model = Team
permission_classes = [
WorkSpaceAdminPermission,
]
search_fields = [
"member__email",
@ -442,7 +479,7 @@ class TeamMemberViewSet(BaseViewSet):
capture_exception(e)
return Response(
{"error": "Something went wrong please try again later"},
status=status.HTTP_500_INTERNAL_SERVER_ERROR,
status=status.HTTP_400_BAD_REQUEST,
)
@ -506,5 +543,47 @@ class UserLastProjectWithWorkspaceEndpoint(BaseAPIView):
capture_exception(e)
return Response(
{"error": "Something went wrong please try again later"},
status=status.HTTP_500_INTERNAL_SERVER_ERROR,
)
status=status.HTTP_400_BAD_REQUEST,
)
class WorkspaceMemberUserEndpoint(BaseAPIView):
def get(self, request, slug):
try:
workspace_member = WorkspaceMember.objects.get(
member=request.user, workspace__slug=slug
)
serializer = WorkSpaceMemberSerializer(workspace_member)
return Response(serializer.data, status=status.HTTP_200_OK)
except (Workspace.DoesNotExist, WorkspaceMember.DoesNotExist):
return Response({"error": "Forbidden"}, status=status.HTTP_403_FORBIDDEN)
except Exception as e:
capture_exception(e)
return Response(
{"error": "Something went wrong please try again later"},
status=status.HTTP_400_BAD_REQUEST,
)
class WorkspaceMemberUserViewsEndpoint(BaseAPIView):
def post(self, request, slug):
try:
workspace_member = WorkspaceMember.objects.get(
workspace__slug=slug, member=request.user
)
workspace_member.view_props = request.data.get("view_props", {})
workspace_member.save()
return Response(status=status.HTTP_200_OK)
except WorkspaceMember.DoesNotExist:
return Response(
{"error": "User not a member of workspace"},
status=status.HTTP_403_FORBIDDEN,
)
except Exception as e:
capture_exception(e)
return Response(
{"error": "Something went wrong please try again later"},
status=status.HTTP_400_BAD_REQUEST,
)

24
apiserver/plane/asgi.py Normal file
View File

@ -0,0 +1,24 @@
import os
from channels.routing import ProtocolTypeRouter, ChannelNameRouter
from django.core.asgi import get_asgi_application
django_asgi_app = get_asgi_application()
os.environ.setdefault("DJANGO_SETTINGS_MODULE", "plane.settings.production")
# Initialize Django ASGI application early to ensure the AppRegistry
# is populated before importing code that may import ORM models.
from plane.api.consumers import IssueConsumer
application = ProtocolTypeRouter(
{
"http": get_asgi_application(),
"channel": ChannelNameRouter(
{
"issue-activites": IssueConsumer.as_asgi(),
}
),
}
)

View File

@ -17,7 +17,7 @@ def magic_link(email, key, token, current_site):
from_email_string = f"Team Plane <team@mailer.plane.so>"
subject = f"Login!"
subject = f"Login for Plane"
context = {"magic_url": abs_url, "code": token}

View File

@ -26,7 +26,7 @@ def project_invitation(email, project_id, token, current_site):
from_email_string = f"Team Plane <team@mailer.plane.so>"
subject = f"Welcome {email}!"
subject = f"{project.created_by.first_name or project.created_by.email} invited you to join {project.name} on Plane"
context = {
"email": email,

View File

@ -28,7 +28,7 @@ def workspace_invitation(email, workspace_id, token, current_site, invitor):
from_email_string = f"Team Plane <team@mailer.plane.so>"
subject = f"Welcome {email}!"
subject = f"{invitor or email} invited you to join {workspace.name} on Plane"
context = {
"email": email,

View File

@ -1,35 +0,0 @@
# from django.contrib import admin
# from plane.db.models import User
# from plane.db.models.workspace import Workspace, WorkspaceMember, WorkspaceMemberInvite
# from plane.db.models.project import Project, ProjectMember, ProjectMemberInvite
# from plane.db.models.cycle import Cycle, CycleIssue
# from plane.db.models.issue import (
# Issue,
# IssueActivity,
# IssueComment,
# IssueProperty,
# TimelineIssue,
# )
# from plane.db.models.shortcut import Shortcut
# from plane.db.models.state import State
# from plane.db.models.social_connection import SocialLoginConnection
# from plane.db.models.view import View
# admin.site.register(User)
# admin.site.register(Workspace)
# admin.site.register(WorkspaceMember)
# admin.site.register(WorkspaceMemberInvite)
# admin.site.register(Project)
# admin.site.register(ProjectMember)
# admin.site.register(ProjectMemberInvite)
# admin.site.register(Cycle)
# admin.site.register(CycleIssue)
# admin.site.register(Issue)
# admin.site.register(IssueActivity)
# admin.site.register(IssueComment)
# admin.site.register(IssueProperty)
# admin.site.register(TimelineIssue)
# admin.site.register(Shortcut)
# admin.site.register(State)
# admin.site.register(SocialLoginConnection)
# admin.site.register(View)

View File

@ -5,48 +5,48 @@ from fieldsignals import post_save_changed
class DbConfig(AppConfig):
name = "plane.db"
def ready(self):
# def ready(self):
post_save_changed.connect(
self.model_activity,
sender=self.get_model("Issue"),
)
# post_save_changed.connect(
# self.model_activity,
# sender=self.get_model("Issue"),
# )
def model_activity(self, sender, instance, changed_fields, **kwargs):
# def model_activity(self, sender, instance, changed_fields, **kwargs):
verb = "created" if instance._state.adding else "changed"
# verb = "created" if instance._state.adding else "changed"
import inspect
# import inspect
for frame_record in inspect.stack():
if frame_record[3] == "get_response":
request = frame_record[0].f_locals["request"]
REQUEST_METHOD = request.method
# for frame_record in inspect.stack():
# if frame_record[3] == "get_response":
# request = frame_record[0].f_locals["request"]
# REQUEST_METHOD = request.method
if REQUEST_METHOD == "POST":
# if REQUEST_METHOD == "POST":
self.get_model("IssueActivity").objects.create(
issue=instance, project=instance.project, actor=instance.created_by
)
# self.get_model("IssueActivity").objects.create(
# issue=instance, project=instance.project, actor=instance.created_by
# )
elif REQUEST_METHOD == "PATCH":
# elif REQUEST_METHOD == "PATCH":
try:
del changed_fields["updated_at"]
del changed_fields["updated_by"]
except KeyError as e:
pass
# try:
# del changed_fields["updated_at"]
# del changed_fields["updated_by"]
# except KeyError as e:
# pass
for field_name, (old, new) in changed_fields.items():
field = field_name
old_value = old
new_value = new
self.get_model("IssueActivity").objects.create(
issue=instance,
verb=verb,
field=field,
old_value=old_value,
new_value=new_value,
project=instance.project,
actor=instance.updated_by,
)
# for field_name, (old, new) in changed_fields.items():
# field = field_name
# old_value = old
# new_value = new
# self.get_model("IssueActivity").objects.create(
# issue=instance,
# verb=verb,
# field=field,
# old_value=old_value,
# new_value=new_value,
# project=instance.project,
# actor=instance.updated_by,
# )

View File

@ -0,0 +1,172 @@
# Generated by Django 3.2.16 on 2023-01-03 19:47
from django.conf import settings
from django.db import migrations, models
import django.db.models.deletion
import uuid
class Migration(migrations.Migration):
dependencies = [
('db', '0011_auto_20221222_2357'),
]
operations = [
migrations.AddField(
model_name='issueactivity',
name='new_identifier',
field=models.UUIDField(null=True),
),
migrations.AddField(
model_name='issueactivity',
name='old_identifier',
field=models.UUIDField(null=True),
),
migrations.AlterField(
model_name='moduleissue',
name='issue',
field=models.OneToOneField(on_delete=django.db.models.deletion.CASCADE, related_name='issue_module', to='db.issue'),
),
migrations.AlterUniqueTogether(
name='moduleissue',
unique_together=set(),
),
migrations.AlterModelTable(
name='cycle',
table='cycles',
),
migrations.AlterModelTable(
name='cycleissue',
table='cycle_issues',
),
migrations.AlterModelTable(
name='fileasset',
table='file_assets',
),
migrations.AlterModelTable(
name='issue',
table='issues',
),
migrations.AlterModelTable(
name='issueactivity',
table='issue_activities',
),
migrations.AlterModelTable(
name='issueassignee',
table='issue_assignees',
),
migrations.AlterModelTable(
name='issueblocker',
table='issue_blockers',
),
migrations.AlterModelTable(
name='issuecomment',
table='issue_comments',
),
migrations.AlterModelTable(
name='issuelabel',
table='issue_labels',
),
migrations.AlterModelTable(
name='issueproperty',
table='issue_properties',
),
migrations.AlterModelTable(
name='issuesequence',
table='issue_sequences',
),
migrations.AlterModelTable(
name='label',
table='labels',
),
migrations.AlterModelTable(
name='module',
table='modules',
),
migrations.AlterModelTable(
name='modulemember',
table='module_members',
),
migrations.AlterModelTable(
name='project',
table='projects',
),
migrations.AlterModelTable(
name='projectidentifier',
table='project_identifiers',
),
migrations.AlterModelTable(
name='projectmember',
table='project_members',
),
migrations.AlterModelTable(
name='projectmemberinvite',
table='project_member_invites',
),
migrations.AlterModelTable(
name='shortcut',
table='shortcuts',
),
migrations.AlterModelTable(
name='socialloginconnection',
table='social_login_connections',
),
migrations.AlterModelTable(
name='state',
table='states',
),
migrations.AlterModelTable(
name='team',
table='teams',
),
migrations.AlterModelTable(
name='teammember',
table='team_members',
),
migrations.AlterModelTable(
name='timelineissue',
table='issue_timelines',
),
migrations.AlterModelTable(
name='user',
table='users',
),
migrations.AlterModelTable(
name='view',
table='views',
),
migrations.AlterModelTable(
name='workspace',
table='workspaces',
),
migrations.AlterModelTable(
name='workspacemember',
table='workspace_members',
),
migrations.AlterModelTable(
name='workspacememberinvite',
table='workspace_member_invites',
),
migrations.CreateModel(
name='ModuleLink',
fields=[
('created_at', models.DateTimeField(auto_now_add=True, verbose_name='Created At')),
('updated_at', models.DateTimeField(auto_now=True, verbose_name='Last Modified At')),
('id', models.UUIDField(db_index=True, default=uuid.uuid4, editable=False, primary_key=True, serialize=False, unique=True)),
('title', models.CharField(max_length=255, null=True)),
('url', models.URLField()),
('created_by', models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='modulelink_created_by', to=settings.AUTH_USER_MODEL, verbose_name='Created By')),
('module', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='link_module', to='db.module')),
('project', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='project_modulelink', to='db.project')),
('updated_by', models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='modulelink_updated_by', to=settings.AUTH_USER_MODEL, verbose_name='Last Modified By')),
('workspace', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='workspace_modulelink', to='db.workspace')),
],
options={
'verbose_name': 'Module Link',
'verbose_name_plural': 'Module Links',
'db_table': 'module_links',
'ordering': ('-created_at',),
},
),
]

View File

@ -0,0 +1,38 @@
# Generated by Django 3.2.16 on 2023-01-06 19:11
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('db', '0012_auto_20230104_0117'),
]
operations = [
migrations.AddField(
model_name='issue',
name='description_html',
field=models.TextField(blank=True),
),
migrations.AddField(
model_name='issue',
name='description_stripped',
field=models.TextField(blank=True),
),
migrations.AddField(
model_name='user',
name='role',
field=models.CharField(blank=True, max_length=300, null=True),
),
migrations.AddField(
model_name='workspacemember',
name='view_props',
field=models.JSONField(blank=True, null=True),
),
migrations.AlterField(
model_name='issue',
name='description',
field=models.JSONField(blank=True),
),
]

View File

@ -0,0 +1,17 @@
# Generated by Django 3.2.16 on 2023-01-07 05:41
from django.db import migrations
class Migration(migrations.Migration):
dependencies = [
('db', '0013_auto_20230107_0041'),
]
operations = [
migrations.AlterUniqueTogether(
name='workspacememberinvite',
unique_together={('email', 'workspace')},
),
]

View File

@ -0,0 +1,28 @@
# Generated by Django 3.2.16 on 2023-01-07 11:06
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('db', '0014_alter_workspacememberinvite_unique_together'),
]
operations = [
migrations.RenameField(
model_name='issuecomment',
old_name='comment',
new_name='comment_stripped',
),
migrations.AddField(
model_name='issuecomment',
name='comment_html',
field=models.TextField(blank=True),
),
migrations.AddField(
model_name='issuecomment',
name='comment_json',
field=models.JSONField(blank=True, null=True),
),
]

View File

@ -0,0 +1,25 @@
# Generated by Django 3.2.16 on 2023-01-07 12:05
from django.db import migrations, models
import django.db.models.deletion
import plane.db.models.asset
class Migration(migrations.Migration):
dependencies = [
('db', '0015_auto_20230107_1636'),
]
operations = [
migrations.AddField(
model_name='fileasset',
name='workspace',
field=models.ForeignKey(null=True, on_delete=django.db.models.deletion.CASCADE, related_name='assets', to='db.workspace'),
),
migrations.AlterField(
model_name='fileasset',
name='asset',
field=models.FileField(upload_to=plane.db.models.asset.get_upload_path, validators=[plane.db.models.asset.file_size]),
),
]

View File

@ -0,0 +1,17 @@
# Generated by Django 3.2.16 on 2023-01-07 17:49
from django.db import migrations
class Migration(migrations.Migration):
dependencies = [
('db', '0016_auto_20230107_1735'),
]
operations = [
migrations.AlterUniqueTogether(
name='workspace',
unique_together=set(),
),
]

View File

@ -37,4 +37,4 @@ from .shortcut import Shortcut
from .view import View
from .module import Module, ModuleMember, ModuleIssue
from .module import Module, ModuleMember, ModuleIssue, ModuleLink

View File

@ -1,24 +1,41 @@
# Django import
from django.db import models
from django.core.exceptions import ValidationError
# Module import
from . import BaseModel
def get_upload_path(instance, filename):
return f"{instance.workspace.id}/{filename}"
def file_size(value):
limit = 5 * 1024 * 1024
if value.size > limit:
raise ValidationError("File too large. Size should not exceed 5 MB.")
class FileAsset(BaseModel):
"""
A file asset.
"""
attributes = models.JSONField(default=dict)
asset = models.FileField(upload_to="library-assets")
asset = models.FileField(
upload_to=get_upload_path,
validators=[
file_size,
],
)
workspace = models.ForeignKey(
"db.Workspace", on_delete=models.CASCADE, null=True, related_name="assets"
)
class Meta:
verbose_name = "File Asset"
verbose_name_plural = "File Assets"
db_table = "file_asset"
db_table = "file_assets"
ordering = ("-created_at",)
def __str__(self):
return self.asset
return str(self.asset)

View File

@ -31,7 +31,7 @@ class Cycle(ProjectBaseModel):
class Meta:
verbose_name = "Cycle"
verbose_name_plural = "Cycles"
db_table = "cycle"
db_table = "cycles"
ordering = ("-created_at",)
def __str__(self):
@ -54,7 +54,7 @@ class CycleIssue(ProjectBaseModel):
class Meta:
verbose_name = "Cycle Issue"
verbose_name_plural = "Cycle Issues"
db_table = "cycle_issue"
db_table = "cycle_issues"
ordering = ("-created_at",)
def __str__(self):

View File

@ -7,6 +7,7 @@ from django.dispatch import receiver
# Module imports
from . import ProjectBaseModel
from plane.utils.html_processor import strip_tags
# TODO: Handle identifiers for Bulk Inserts - nk
class Issue(ProjectBaseModel):
@ -31,7 +32,9 @@ class Issue(ProjectBaseModel):
related_name="state_issue",
)
name = models.CharField(max_length=255, verbose_name="Issue Name")
description = models.JSONField(verbose_name="Issue Description", blank=True)
description = models.JSONField(blank=True)
description_html = models.TextField(blank=True)
description_stripped = models.TextField(blank=True)
priority = models.CharField(
max_length=30,
choices=PRIORITY_CHOICES,
@ -57,7 +60,7 @@ class Issue(ProjectBaseModel):
class Meta:
verbose_name = "Issue"
verbose_name_plural = "Issues"
db_table = "issue"
db_table = "issues"
ordering = ("-created_at",)
def save(self, *args, **kwargs):
@ -81,6 +84,11 @@ class Issue(ProjectBaseModel):
)
except ImportError:
pass
# Strip the html tags using html parser
self.description_stripped = (
strip_tags(self.description_html) if self.description_html != "" else ""
)
super(Issue, self).save(*args, **kwargs)
def __str__(self):
@ -99,7 +107,7 @@ class IssueBlocker(ProjectBaseModel):
class Meta:
verbose_name = "Issue Blocker"
verbose_name_plural = "Issue Blockers"
db_table = "issue_blocker"
db_table = "issue_blockers"
ordering = ("-created_at",)
def __str__(self):
@ -120,7 +128,7 @@ class IssueAssignee(ProjectBaseModel):
unique_together = ["issue", "assignee"]
verbose_name = "Issue Assignee"
verbose_name_plural = "Issue Assignees"
db_table = "issue_assignee"
db_table = "issue_assignees"
ordering = ("-created_at",)
def __str__(self):
@ -156,11 +164,13 @@ class IssueActivity(ProjectBaseModel):
null=True,
related_name="issue_activities",
)
old_identifier = models.UUIDField(null=True)
new_identifier = models.UUIDField(null=True)
class Meta:
verbose_name = "Issue Activity"
verbose_name_plural = "Issue Activities"
db_table = "issue_activity"
db_table = "issue_activities"
ordering = ("-created_at",)
def __str__(self):
@ -178,7 +188,7 @@ class TimelineIssue(ProjectBaseModel):
class Meta:
verbose_name = "Timeline Issue"
verbose_name_plural = "Timeline Issues"
db_table = "issue_timeline"
db_table = "issue_timelines"
ordering = ("-created_at",)
def __str__(self):
@ -187,7 +197,9 @@ class TimelineIssue(ProjectBaseModel):
class IssueComment(ProjectBaseModel):
comment = models.TextField(verbose_name="Comment", blank=True)
comment_stripped = models.TextField(verbose_name="Comment", blank=True)
comment_json = models.JSONField(blank=True, null=True)
comment_html = models.TextField(blank=True)
attachments = ArrayField(models.URLField(), size=10, blank=True, default=list)
issue = models.ForeignKey(Issue, on_delete=models.CASCADE)
# System can also create comment
@ -198,10 +210,15 @@ class IssueComment(ProjectBaseModel):
null=True,
)
def save(self, *args, **kwargs):
self.comment_stripped = strip_tags(self.comment_html) if self.comment_html != "" else ""
return super(IssueComment, self).save(*args, **kwargs)
class Meta:
verbose_name = "Issue Comment"
verbose_name_plural = "Issue Comments"
db_table = "issue_comment"
db_table = "issue_comments"
ordering = ("-created_at",)
def __str__(self):
@ -220,7 +237,7 @@ class IssueProperty(ProjectBaseModel):
class Meta:
verbose_name = "Issue Property"
verbose_name_plural = "Issue Properties"
db_table = "issue_property"
db_table = "issue_properties"
ordering = ("-created_at",)
unique_together = ["user", "project"]
@ -245,7 +262,7 @@ class Label(ProjectBaseModel):
class Meta:
verbose_name = "Label"
verbose_name_plural = "Labels"
db_table = "label"
db_table = "labels"
ordering = ("-created_at",)
def __str__(self):
@ -264,7 +281,7 @@ class IssueLabel(ProjectBaseModel):
class Meta:
verbose_name = "Issue Label"
verbose_name_plural = "Issue Labels"
db_table = "issue_label"
db_table = "issue_labels"
ordering = ("-created_at",)
def __str__(self):
@ -282,7 +299,7 @@ class IssueSequence(ProjectBaseModel):
class Meta:
verbose_name = "Issue Sequence"
verbose_name_plural = "Issue Sequences"
db_table = "issue_sequence"
db_table = "issue_sequences"
ordering = ("-created_at",)

View File

@ -41,11 +41,12 @@ class Module(ProjectBaseModel):
through_fields=("module", "member"),
)
class Meta:
unique_together = ["name", "project"]
verbose_name = "Module"
verbose_name_plural = "Modules"
db_table = "module"
db_table = "modules"
ordering = ("-created_at",)
def __str__(self):
@ -61,7 +62,7 @@ class ModuleMember(ProjectBaseModel):
unique_together = ["module", "member"]
verbose_name = "Module Member"
verbose_name_plural = "Module Members"
db_table = "module_member"
db_table = "module_members"
ordering = ("-created_at",)
def __str__(self):
@ -73,12 +74,11 @@ class ModuleIssue(ProjectBaseModel):
module = models.ForeignKey(
"db.Module", on_delete=models.CASCADE, related_name="issue_module"
)
issue = models.ForeignKey(
issue = models.OneToOneField(
"db.Issue", on_delete=models.CASCADE, related_name="issue_module"
)
class Meta:
unique_together = ["module", "issue"]
verbose_name = "Module Issue"
verbose_name_plural = "Module Issues"
db_table = "module_issues"
@ -86,3 +86,19 @@ class ModuleIssue(ProjectBaseModel):
def __str__(self):
return f"{self.module.name} {self.issue.name}"
class ModuleLink(ProjectBaseModel):
title = models.CharField(max_length=255, null=True)
url = models.URLField()
module = models.ForeignKey(Module, on_delete=models.CASCADE, related_name="link_module")
class Meta:
verbose_name = "Module Link"
verbose_name_plural = "Module Links"
db_table = "module_links"
ordering = ("-created_at",)
def __str__(self):
return f"{self.module.name} {self.url}"

View File

@ -72,7 +72,7 @@ class Project(BaseModel):
unique_together = [["identifier", "workspace"], ["name", "workspace"]]
verbose_name = "Project"
verbose_name_plural = "Projects"
db_table = "project"
db_table = "projects"
ordering = ("-created_at",)
def save(self, *args, **kwargs):
@ -109,7 +109,7 @@ class ProjectMemberInvite(ProjectBaseModel):
class Meta:
verbose_name = "Project Member Invite"
verbose_name_plural = "Project Member Invites"
db_table = "project_member_invite"
db_table = "project_member_invites"
ordering = ("-created_at",)
def __str__(self):
@ -134,7 +134,7 @@ class ProjectMember(ProjectBaseModel):
unique_together = ["project", "member"]
verbose_name = "Project Member"
verbose_name_plural = "Project Members"
db_table = "project_member"
db_table = "project_members"
ordering = ("-created_at",)
def __str__(self):
@ -156,5 +156,5 @@ class ProjectIdentifier(AuditModel):
unique_together = ["name", "workspace"]
verbose_name = "Project Identifier"
verbose_name_plural = "Project Identifiers"
db_table = "project_identifier"
db_table = "project_identifiers"
ordering = ("-created_at",)

View File

@ -18,7 +18,7 @@ class Shortcut(ProjectBaseModel):
class Meta:
verbose_name = "Shortcut"
verbose_name_plural = "Shortcuts"
db_table = "shortcut"
db_table = "shortcuts"
ordering = ("-created_at",)
def __str__(self):

View File

@ -26,7 +26,7 @@ class SocialLoginConnection(BaseModel):
class Meta:
verbose_name = "Social Login Connection"
verbose_name_plural = "Social Login Connections"
db_table = "social_login_connection"
db_table = "social_login_connections"
ordering = ("-created_at",)
def __str__(self):

View File

@ -32,7 +32,7 @@ class State(ProjectBaseModel):
unique_together = ["name", "project"]
verbose_name = "State"
verbose_name_plural = "States"
db_table = "state"
db_table = "states"
ordering = ("sequence",)
def save(self, *args, **kwargs):

View File

@ -67,6 +67,7 @@ class User(AbstractBaseUser, PermissionsMixin):
token_updated_at = models.DateTimeField(null=True)
last_workspace_id = models.UUIDField(null=True)
my_issues_prop = models.JSONField(null=True)
role = models.CharField(max_length=300, null=True, blank=True)
USERNAME_FIELD = "email"
@ -77,7 +78,7 @@ class User(AbstractBaseUser, PermissionsMixin):
class Meta:
verbose_name = "User"
verbose_name_plural = "Users"
db_table = "user"
db_table = "users"
ordering = ("-created_at",)
def __str__(self):
@ -105,7 +106,7 @@ def send_welcome_email(sender, instance, created, **kwargs):
to_email = instance.email
from_email_string = f"Team Plane <team@mailer.plane.so>"
subject = f"Welcome {first_name}!"
subject = f"Welcome to Plane ✈️!"
context = {"first_name": first_name, "email": instance.email}

View File

@ -14,7 +14,7 @@ class View(ProjectBaseModel):
class Meta:
verbose_name = "View"
verbose_name_plural = "Views"
db_table = "view"
db_table = "views"
ordering = ("-created_at",)
def __str__(self):

View File

@ -1,6 +1,5 @@
# Django imports
from django.db import models
from django.template.defaultfilters import slugify
from django.conf import settings
# Module imports
@ -31,15 +30,11 @@ class Workspace(BaseModel):
return self.name
class Meta:
unique_together = ["name", "owner"]
verbose_name = "Workspace"
verbose_name_plural = "Workspaces"
db_table = "workspace"
db_table = "workspaces"
ordering = ("-created_at",)
def save(self, *args, **kwargs):
self.slug = slugify(self.name)
return super().save(*args, **kwargs)
class WorkspaceMember(BaseModel):
@ -53,12 +48,13 @@ class WorkspaceMember(BaseModel):
)
role = models.PositiveSmallIntegerField(choices=ROLE_CHOICES, default=10)
company_role = models.TextField(null=True, blank=True)
view_props = models.JSONField(null=True, blank=True)
class Meta:
unique_together = ["workspace", "member"]
verbose_name = "Workspace Member"
verbose_name_plural = "Workspace Members"
db_table = "workspace_member"
db_table = "workspace_members"
ordering = ("-created_at",)
def __str__(self):
@ -78,9 +74,10 @@ class WorkspaceMemberInvite(BaseModel):
role = models.PositiveSmallIntegerField(choices=ROLE_CHOICES, default=10)
class Meta:
unique_together = ["email", "workspace"]
verbose_name = "Workspace Member Invite"
verbose_name_plural = "Workspace Member Invites"
db_table = "workspace_member_invite"
db_table = "workspace_member_invites"
ordering = ("-created_at",)
def __str__(self):
@ -109,7 +106,7 @@ class Team(BaseModel):
unique_together = ["name", "workspace"]
verbose_name = "Team"
verbose_name_plural = "Teams"
db_table = "team"
db_table = "teams"
ordering = ("-created_at",)
@ -130,5 +127,5 @@ class TeamMember(BaseModel):
unique_together = ["team", "member"]
verbose_name = "Team Member"
verbose_name_plural = "Team Members"
db_table = "team_member"
db_table = "team_members"
ordering = ("-created_at",)

View File

@ -6,7 +6,7 @@ from datetime import timedelta
BASE_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
SECRET_KEY = os.environ.get('SECRET_KEY')
SECRET_KEY = os.environ.get("SECRET_KEY")
# SECURITY WARNING: don't run with debug turned on in production!
DEBUG = True
@ -36,6 +36,7 @@ INSTALLED_APPS = [
"taggit",
"fieldsignals",
"django_rq",
"channels",
]
MIDDLEWARE = [
@ -109,6 +110,7 @@ JWT_AUTH = {
}
WSGI_APPLICATION = "plane.wsgi.application"
ASGI_APPLICATION = "plane.asgi.application"
# Django Sites

View File

@ -64,4 +64,13 @@ RQ_QUEUES = {
},
}
WEB_URL = "http://localhost:3000"
WEB_URL = "http://localhost:3000"
CHANNEL_LAYERS = {
"default": {
"BACKEND": "channels_redis.core.RedisChannelLayer",
"CONFIG": {
"hosts": [(REDIS_HOST, REDIS_PORT)],
},
},
}

View File

@ -1,22 +1,27 @@
"""Production settings and globals."""
from plane.settings.local import WEB_URL
from .common import * # noqa
import ssl
from typing import Optional
from urllib.parse import urlparse
import dj_database_url
from urllib.parse import urlparse
from redis.asyncio.connection import Connection, RedisSSLContext
import sentry_sdk
from sentry_sdk.integrations.django import DjangoIntegration
from sentry_sdk.integrations.redis import RedisIntegration
from .common import * # noqa
# Database
DEBUG = True
DEBUG = False
DATABASES = {
"default": {
"ENGINE": "django.db.backends.postgresql_psycopg2",
"NAME": "plane",
"USER": os.environ.get('PGUSER'),
"PASSWORD": os.environ.get('PGPASSWORD'),
"HOST": os.environ.get('PGHOST'),
"USER": os.environ.get("PGUSER"),
"PASSWORD": os.environ.get("PGPASSWORD"),
"HOST": os.environ.get("PGHOST"),
}
}
@ -180,4 +185,65 @@ RQ_QUEUES = {
}
}
WEB_URL = os.environ.get("WEB_URL")
class CustomSSLConnection(Connection):
def __init__(
self,
ssl_context: Optional[str] = None,
**kwargs,
):
super().__init__(**kwargs)
self.ssl_context = RedisSSLContext(ssl_context)
class RedisSSLContext:
__slots__ = ("context",)
def __init__(
self,
ssl_context,
):
self.context = ssl_context
def get(self):
return self.context
url = urlparse(os.environ.get("REDIS_URL"))
DOCKERIZED = os.environ.get("DOCKERIZED", False) # Set the variable true if running in docker-compose environment
if not DOCKERIZED:
ssl_context = ssl.SSLContext()
ssl_context.check_hostname = False
CHANNEL_LAYERS = {
"default": {
"BACKEND": "channels_redis.core.RedisChannelLayer",
"CONFIG": {
"hosts": [
{
"host": url.hostname,
"port": url.port,
"username": url.username,
"password": url.password,
"connection_class": CustomSSLConnection,
"ssl_context": ssl_context,
}
],
},
},
}
CHANNEL_LAYERS = {
"default": {
"BACKEND": "channels_redis.core.RedisChannelLayer",
"CONFIG": {
"hosts": [(os.environ.get("REDIS_URL"))],
},
},
}
WEB_URL = os.environ.get("WEB_URL")

View File

@ -3,18 +3,21 @@ from django.conf import settings
from urllib.parse import urlparse
def redis_instance():
if settings.REDIS_URL:
url = urlparse(settings.REDIS_URL)
ri = redis.Redis(
host=url.hostname,
port=url.port,
password=url.password,
ssl=True,
ssl_cert_reqs=None,
)
# Run in local redis url is false
if not settings.REDIS_URL:
ri = redis.StrictRedis(host=settings.REDIS_HOST, port=settings.REDIS_PORT, db=0)
else:
ri = redis.StrictRedis(
host=settings.REDIS_HOST, port=settings.REDIS_PORT, db=0
)
# Run in prod redis url is true check with dockerized value
if settings.DOCKERIZED:
ri = redis.from_url(settings.REDIS_URL, db=0)
else:
url = urlparse(settings.REDIS_URL)
ri = redis.Redis(
host=url.hostname,
port=url.port,
password=url.password,
ssl=True,
ssl_cert_reqs=None,
)
return ri

View File

@ -1,13 +1,18 @@
"""Production settings and globals."""
from plane.settings.local import WEB_URL
from .common import * # noqa
import ssl
from typing import Optional
from urllib.parse import urlparse
import dj_database_url
from urllib.parse import urlparse
from redis.asyncio.connection import Connection, RedisSSLContext
import sentry_sdk
from sentry_sdk.integrations.django import DjangoIntegration
from sentry_sdk.integrations.redis import RedisIntegration
from .common import * # noqa
# Database
DEBUG = False
DATABASES = {
@ -180,4 +185,52 @@ RQ_QUEUES = {
}
}
WEB_URL = os.environ.get("WEB_URL")
class CustomSSLConnection(Connection):
def __init__(
self,
ssl_context: Optional[str] = None,
**kwargs,
):
super().__init__(**kwargs)
self.ssl_context = RedisSSLContext(ssl_context)
class RedisSSLContext:
__slots__ = (
"context",
)
def __init__(
self,
ssl_context,
):
self.context = ssl_context
def get(self):
return self.context
url = urlparse(os.environ.get("REDIS_URL"))
ssl_context = ssl.SSLContext()
ssl_context.check_hostname = False
CHANNEL_LAYERS = {
'default': {
'BACKEND': 'channels_redis.core.RedisChannelLayer',
'CONFIG': {
'hosts': [
{
'host': url.hostname,
'port': url.port,
'username': url.username,
'password': url.password,
'connection_class': CustomSSLConnection,
'ssl_context': ssl_context,
}
],
}
},
}
WEB_URL = os.environ.get("WEB_URL")

View File

@ -0,0 +1,24 @@
from io import StringIO
from html.parser import HTMLParser
class MLStripper(HTMLParser):
"""
Markup Language Stripper
"""
def __init__(self):
super().__init__()
self.reset()
self.strict = False
self.convert_charrefs= True
self.text = StringIO()
def handle_data(self, d):
self.text.write(d)
def get_data(self):
return self.text.getvalue()
def strip_tags(html):
s = MLStripper()
s.feed(html)
return s.get_data()

View File

@ -1,11 +1,11 @@
# base requirements
Django==3.2.14
Django==3.2.16
django-braces==1.15.0
django-taggit==2.1.0
psycopg2==2.9.3
django-oauth-toolkit==2.0.0
mistune==2.0.2
mistune==2.0.3
djangorestframework==3.13.1
redis==4.2.2
django-nested-admin==3.4.0
@ -25,4 +25,7 @@ dj_rest_auth==2.2.5
google-auth==2.9.1
google-api-python-client==2.55.0
django-rq==2.5.1
django-redis==5.2.0
django-redis==5.2.0
channels==4.0.0
channels-redis==4.0.0
uvicorn==0.20.0

View File

@ -1 +1 @@
python-3.9.16
python-3.11.1

View File

@ -1,11 +1,367 @@
<!DOCTYPE html>
<html>
<p>
Login,<br /><br />
Welcome! Login with the link below <br />
{{magic_url}} <br> or enter the code.<br/>
{{code}}
<br /><br />
</p>
<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Strict//EN" "http://www.w3.org/TR/xhtml1/DTD/xhtml1-strict.dtd">
<html xmlns="http://www.w3.org/1999/xhtml" xmlns:v="urn:schemas-microsoft-com:vml" xmlns:o="urn:schemas-microsoft-com:office:office">
<head>
<meta http-equiv="Content-Type" content="text/html; charset=utf-8">
<meta http-equiv="X-UA-Compatible" content="IE=edge">
<meta name="format-detection" content="telephone=no">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Login for Plane</title>
<style type="text/css" emogrify="no">#outlook a { padding:0; } .ExternalClass { width:100%; } .ExternalClass, .ExternalClass p, .ExternalClass span, .ExternalClass font, .ExternalClass td, .ExternalClass div { line-height: 100%; } table td { border-collapse: collapse; mso-line-height-rule: exactly; } .editable.image { font-size: 0 !important; line-height: 0 !important; } .nl2go_preheader { display: none !important; mso-hide:all !important; mso-line-height-rule: exactly; visibility: hidden !important; line-height: 0px !important; font-size: 0px !important; } body { width:100% !important; -webkit-text-size-adjust:100%; -ms-text-size-adjust:100%; margin:0; padding:0; } img { outline:none; text-decoration:none; -ms-interpolation-mode: bicubic; } a img { border:none; } table { border-collapse:collapse; mso-table-lspace:0pt; mso-table-rspace:0pt; } th { font-weight: normal; text-align: left; } *[class="gmail-fix"] { display: none !important; } </style>
<style type="text/css" emogrify="no"> @media (max-width: 600px) { .gmx-killpill { content: ' \03D1';} } </style>
<style type="text/css" emogrify="no">@media (max-width: 600px) { .gmx-killpill { content: ' \03D1';} .r0-c { box-sizing: border-box !important; text-align: center !important; valign: top !important; width: 320px !important } .r1-o { border-style: solid !important; margin: 0 auto 0 auto !important; width: 320px !important } .r2-i { background-color: #ffffff !important } .r3-c { box-sizing: border-box !important; text-align: center !important; valign: top !important; width: 100% !important } .r4-o { border-style: solid !important; margin: 0 auto 0 auto !important; margin-top: 20px !important; width: 100% !important } .r5-i { background-color: #f8f9fa !important; padding-bottom: 20px !important; padding-left: 10px !important; padding-right: 10px !important; padding-top: 20px !important } .r6-c { box-sizing: border-box !important; display: block !important; valign: top !important; width: 100% !important } .r7-o { border-style: solid !important; width: 100% !important } .r8-i { padding-left: 0px !important; padding-right: 0px !important } .r9-o { border-style: solid !important; margin: 0 auto 0 auto !important; width: 100% !important } .r10-i { padding-bottom: 35px !important; padding-top: 15px !important } .r11-c { box-sizing: border-box !important; text-align: left !important; valign: top !important; width: 100% !important } .r12-o { border-style: solid !important; margin: 0 auto 0 0 !important; width: 100% !important } .r13-i { padding-left: 20px !important; padding-right: 20px !important; padding-top: 0px !important; text-align: center !important } .r14-o { border-style: solid !important; margin: 0 auto 0 auto !important; margin-bottom: 20px !important; margin-top: 20px !important; width: 100% !important } .r15-i { text-align: center !important } .r16-r { background-color: #ffffff !important; border-color: #3f76ff !important; border-radius: 4px !important; border-width: 1px !important; box-sizing: border-box; height: initial !important; padding-bottom: 7px !important; padding-left: 20px !important; padding-right: 20px !important; padding-top: 7px !important; text-align: center !important; width: 100% !important } .r17-i { padding-bottom: 15px !important; padding-left: 20px !important; padding-right: 20px !important; padding-top: 15px !important; text-align: left !important } .r18-i { background-color: #eff2f7 !important; padding-bottom: 15px !important; padding-top: 15px !important; text-align: center !important } .r19-i { background-color: #eff2f7 !important; padding-bottom: 20px !important; padding-left: 15px !important; padding-right: 15px !important; padding-top: 20px !important } .r20-i { padding-bottom: 15px !important; padding-top: 15px !important } .r21-i { color: #3b3f44 !important; padding-bottom: 0px !important; padding-top: 0px !important; text-align: center !important } .r22-c { box-sizing: border-box !important; text-align: center !important; width: 100% !important } .r23-c { box-sizing: border-box !important; width: 100% !important } .r24-i { font-size: 0px !important; padding-bottom: 15px !important; padding-left: 65px !important; padding-right: 65px !important; padding-top: 15px !important } .r25-c { box-sizing: border-box !important; width: 32px !important } .r26-o { border-style: solid !important; margin-right: 8px !important; width: 32px !important } .r27-i { padding-bottom: 5px !important; padding-top: 5px !important } .r28-o { border-style: solid !important; margin-right: 0px !important; width: 32px !important } .r29-i { color: #3b3f44 !important; padding-bottom: 15px !important; padding-top: 15px !important; text-align: center !important } .r30-i { padding-bottom: 15px !important; padding-left: 0px !important; padding-right: 0px !important; padding-top: 0px !important } .r31-c { box-sizing: border-box !important; text-align: center !important; valign: top !important; width: 129px !important } .r32-o { border-style: solid !important; margin: 0 auto 0 auto !important; width: 129px !important } body { -webkit-text-size-adjust: none } .nl2go-responsive-hide { display: none } .nl2go-body-table { min-width: unset !important } .mobshow { height: auto !important; overflow: visible !important; max-height: unset !important; visibility: visible !important; border: none !important } .resp-table { display: inline-table !important } .magic-resp { display: table-cell !important } } </style>
<!--[if !mso]><!-->
<style type="text/css" emogrify="no">@import url("https://fonts.googleapis.com/css2?family=Bitter&family=Roboto"); </style>
<!--<![endif]-->
<style type="text/css">p, h1, h2, h3, h4, ol, ul { margin: 0; } a, a:link { color: #0092ff; text-decoration: underline } .nl2go-default-textstyle { color: #3b3f44; font-family: arial,helvetica,sans-serif; font-size: 16px; line-height: 1.5 } .default-button { border-radius: 4px; color: #ffffff; font-family: arial,helvetica,sans-serif; font-size: 16px; font-style: normal; font-weight: bold; line-height: 1.15; text-decoration: none; width: 50% } .default-heading1 { color: #1F2D3D; font-family: arial,helvetica,sans-serif; font-size: 36px } .default-heading2 { color: #1F2D3D; font-family: arial,helvetica,sans-serif; font-size: 32px } .default-heading3 { color: #1F2D3D; font-family: arial,helvetica,sans-serif; font-size: 24px } .default-heading4 { color: #1F2D3D; font-family: arial,helvetica,sans-serif; font-size: 18px } a[x-apple-data-detectors] { color: inherit !important; text-decoration: inherit !important; font-size: inherit !important; font-family: inherit !important; font-weight: inherit !important; line-height: inherit !important; } .no-show-for-you { border: none; display: none; float: none; font-size: 0; height: 0; line-height: 0; max-height: 0; mso-hide: all; overflow: hidden; table-layout: fixed; visibility: hidden; width: 0; } </style>
<!--[if mso]>
<xml>
<o:OfficeDocumentSettings>
<o:AllowPNG/>
<o:PixelsPerInch>96</o:PixelsPerInch>
</o:OfficeDocumentSettings>
</xml>
<![endif]-->
<style type="text/css">a:link{color: #0092ff; text-decoration: underline;}</style>
</head>
<body bgcolor="#ffffff" text="#3b3f44" link="#0092ff" yahoo="fix" style="background-color: #ffffff; padding-bottom: 0px; padding-top: 0px;">
<table cellspacing="0" cellpadding="0" border="0" role="presentation" class="nl2go-body-table" width="100%" style="background-color: #ffffff; width: 100%;">
<tr>
<td align="center" class="r0-c">
<table cellspacing="0" cellpadding="0" border="0" role="presentation" width="600" class="r1-o" style="table-layout: fixed; width: 600px;">
<tr>
<td valign="top" class="r2-i" style="background-color: #ffffff;">
<table width="100%" cellspacing="0" cellpadding="0" border="0" role="presentation">
<tr>
<td class="r3-c" align="center">
<table cellspacing="0" cellpadding="0" border="0" role="presentation" width="100%" class="r4-o" style="table-layout: fixed; width: 100%;">
<!-- -->
<tr class="nl2go-responsive-hide">
<td height="20" style="font-size: 20px; line-height: 20px;">­</td>
</tr>
<tr class="nl2go-responsive-hide">
<td height="20" style="font-size: 20px; line-height: 20px; background-color: #f8f9fa;">­</td>
</tr>
<tr>
<td class="r5-i" style="background-color: #f8f9fa;">
<table width="100%" cellspacing="0" cellpadding="0" border="0" role="presentation">
<tr>
<th width="100%" valign="top" class="r6-c" style="font-weight: normal;">
<table cellspacing="0" cellpadding="0" border="0" role="presentation" width="100%" class="r7-o" style="table-layout: fixed; width: 100%;">
<!-- -->
<tr>
<td valign="top" class="r8-i">
<table width="100%" cellspacing="0" cellpadding="0" border="0" role="presentation">
<tr>
<td class="r3-c" align="center">
<table cellspacing="0" cellpadding="0" border="0" role="presentation" width="120" class="r9-o" style="table-layout: fixed; width: 120px;">
<tr class="nl2go-responsive-hide">
<td height="15" style="font-size: 15px; line-height: 15px;">­</td>
</tr>
<tr>
<td class="r10-i" style="font-size: 0px; line-height: 0px;"> <img src="https://ik.imagekit.io/w2okwbtu2/Plane_Logo_pIhtbyIoa.png?ik-sdk-version=javascript-1.4.3&updatedAt=1670873447444" width="120" border="0" class="" style="display: block; width: 100%;"></td>
</tr>
<tr class="nl2go-responsive-hide">
<td height="35" style="font-size: 35px; line-height: 35px;">­</td>
</tr>
</table>
</td>
</tr>
<tr>
<td class="r11-c" align="left">
<table cellspacing="0" cellpadding="0" border="0" role="presentation" width="100%" class="r12-o" style="table-layout: fixed; width: 100%;">
<tr>
<td class="nl2go-responsive-hide" width="20" style="font-size: 0px; line-height: 1px;">­ </td>
<td align="center" valign="top" class="r13-i nl2go-default-textstyle" style="color: #3b3f44; font-family: arial,helvetica,sans-serif; font-size: 16px; line-height: 1.5; text-align: center;">
<div>
<h3 class="default-heading3" style="margin: 0; color: #1f2d3d; font-family: arial,helvetica,sans-serif; font-size: 24px;">Your login code for Plane</h3>
</div>
</td>
<td class="nl2go-responsive-hide" width="20" style="font-size: 0px; line-height: 1px;">­ </td>
</tr>
</table>
</td>
</tr>
<tr>
<td class="r3-c" align="center">
<table cellspacing="0" cellpadding="0" border="0" role="presentation" width="300" class="r14-o" style="table-layout: fixed; width: 300px;">
<tr class="nl2go-responsive-hide">
<td height="20" style="font-size: 20px; line-height: 20px;">­</td>
</tr>
<tr>
<td height="18" align="center" valign="top" class="r15-i nl2go-default-textstyle" style="color: #3b3f44; font-family: arial,helvetica,sans-serif; font-size: 16px; line-height: 1.5;">
<!--[if mso]>
<v:roundrect xmlns:v="urn:schemas-microsoft-com:vml" xmlns:w="urn:schemas-microsoft-com:office:word" href="https://app.plane.so/" style="v-text-anchor:middle; height: 33px; width: 301px;" arcsize="12%" fillcolor="#ffffff" strokecolor="#3f76ff" strokeweight="1px" data-btn="1">
<w:anchorlock/>
<div style="display:none;">
<center class="default-button">
<p><span style="color:#3F76FF;font-size:14px;">Open Plane</span></p>
</center>
</div>
</v:roundrect>
<![endif]--> <!--[if !mso]><!-- -->
<a href={{magic_url}} class="r16-r default-button" target="_blank" data-btn="1" style="font-style: normal; font-weight: bold; line-height: 1.15; text-decoration: none; border-style: solid; word-wrap: break-word; display: inline-block; -webkit-text-size-adjust: none; mso-hide: all; background-color: #ffffff; border-color: #3f76ff; border-radius: 4px; border-width: 1px; color: #ffffff; font-family: arial,helvetica,sans-serif; font-size: 16px; height: 18px; padding-bottom: 7px; padding-left: 20px; padding-right: 20px; padding-top: 7px; width: 258px;">
<p style="margin: 0;"><span style="color: #3F76FF; font-size: 14px;">Open Plane</span></p>
</a>
<!--<![endif]-->
</td>
</tr>
<tr class="nl2go-responsive-hide">
<td height="20" style="font-size: 20px; line-height: 20px;">­</td>
</tr>
</table>
</td>
</tr>
<tr>
<td class="r11-c" align="left">
<table cellspacing="0" cellpadding="0" border="0" role="presentation" width="100%" class="r12-o" style="table-layout: fixed; width: 100%;">
<tr class="nl2go-responsive-hide">
<td height="15" width="20" style="font-size: 15px; line-height: 15px;">­ </td>
<td height="15" style="font-size: 15px; line-height: 15px;">­</td>
<td height="15" width="20" style="font-size: 15px; line-height: 15px;">­ </td>
</tr>
<tr>
<td class="nl2go-responsive-hide" width="20" style="font-size: 0px; line-height: 1px;">­ </td>
<td align="left" valign="top" class="r17-i nl2go-default-textstyle" style="color: #3b3f44; font-family: arial,helvetica,sans-serif; font-size: 16px; line-height: 1.5; text-align: left;">
<div>
<p style="margin: 0;">This link and code will only be valid for the next 10 minutes. If the link does not work, you can use the login verification code directly:</p>
</div>
</td>
<td class="nl2go-responsive-hide" width="20" style="font-size: 0px; line-height: 1px;">­ </td>
</tr>
<tr class="nl2go-responsive-hide">
<td height="15" width="20" style="font-size: 15px; line-height: 15px;">­ </td>
<td height="15" style="font-size: 15px; line-height: 15px;">­</td>
<td height="15" width="20" style="font-size: 15px; line-height: 15px;">­ </td>
</tr>
</table>
</td>
</tr>
<tr>
<td class="r3-c" align="center">
<table cellspacing="0" cellpadding="0" border="0" role="presentation" width="50%" class="r9-o" style="border-collapse: separate; border-radius: 10px; table-layout: fixed; width: 50%;">
<tr>
<td align="center" valign="top" class="r18-i nl2go-default-textstyle" style="color: #3b3f44; font-family: arial,helvetica,sans-serif; font-size: 16px; line-height: 1.5; background-color: #eff2f7; border-radius: 10px; padding-bottom: 15px; padding-top: 15px; text-align: center;">
<div>
<p style="margin: 0;"><strong>{{code}}</strong></p>
</div>
</td>
</tr>
</table>
</td>
</tr>
<tr>
<td class="r11-c" align="left">
<table cellspacing="0" cellpadding="0" border="0" role="presentation" width="100%" class="r12-o" style="table-layout: fixed; width: 100%;">
<tr class="nl2go-responsive-hide">
<td height="15" width="20" style="font-size: 15px; line-height: 15px;">­ </td>
<td height="15" style="font-size: 15px; line-height: 15px;">­</td>
<td height="15" width="20" style="font-size: 15px; line-height: 15px;">­ </td>
</tr>
<tr>
<td class="nl2go-responsive-hide" width="20" style="font-size: 0px; line-height: 1px;">­ </td>
<td align="left" valign="top" class="r17-i nl2go-default-textstyle" style="color: #3b3f44; font-family: arial,helvetica,sans-serif; font-size: 16px; line-height: 1.5; text-align: left;">
<div>
<p style="margin: 0;"><span style="font-size: 12px;">Note: 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 </span><a href="https://discord.gg/A92xrEGCge" target="_blank" style="color: #0092ff; text-decoration: underline;"><span style="font-size: 12px;">Discord</span></a><span style="font-size: 12px;"> or </span><a href="https://github.com/makeplane/plane" target="_blank" style="color: #0092ff; text-decoration: underline;"><span style="font-size: 12px;">GitHub</span></a><span style="font-size: 12px;">, and we will use your feedback to improve on our upcoming releases.</span></p>
</div>
</td>
<td class="nl2go-responsive-hide" width="20" style="font-size: 0px; line-height: 1px;">­ </td>
</tr>
<tr class="nl2go-responsive-hide">
<td height="15" width="20" style="font-size: 15px; line-height: 15px;">­ </td>
<td height="15" style="font-size: 15px; line-height: 15px;">­</td>
<td height="15" width="20" style="font-size: 15px; line-height: 15px;">­ </td>
</tr>
</table>
</td>
</tr>
</table>
</td>
</tr>
</table>
</th>
</tr>
</table>
</td>
</tr>
<tr class="nl2go-responsive-hide">
<td height="20" style="font-size: 20px; line-height: 20px; background-color: #f8f9fa;">­</td>
</tr>
</table>
</td>
</tr>
<tr>
<td class="r3-c" align="center">
<table cellspacing="0" cellpadding="0" border="0" role="presentation" width="100%" class="r9-o" style="table-layout: fixed; width: 100%;">
<!-- -->
<tr class="nl2go-responsive-hide">
<td height="20" style="font-size: 20px; line-height: 20px; background-color: #eff2f7;">­</td>
</tr>
<tr>
<td class="r19-i" style="background-color: #eff2f7;">
<table width="100%" cellspacing="0" cellpadding="0" border="0" role="presentation">
<tr>
<th width="100%" valign="top" class="r6-c" style="font-weight: normal;">
<table cellspacing="0" cellpadding="0" border="0" role="presentation" width="100%" class="r7-o" style="table-layout: fixed; width: 100%;">
<!-- -->
<tr>
<td class="nl2go-responsive-hide" width="15" style="font-size: 0px; line-height: 1px;">­ </td>
<td valign="top" class="r8-i">
<table width="100%" cellspacing="0" cellpadding="0" border="0" role="presentation">
<tr>
<td class="r3-c" align="center">
<table cellspacing="0" cellpadding="0" border="0" role="presentation" width="57" class="r9-o" style="table-layout: fixed; width: 57px;">
<tr class="nl2go-responsive-hide">
<td height="15" style="font-size: 15px; line-height: 15px;">­</td>
</tr>
<tr>
<td class="r20-i" style="font-size: 0px; line-height: 0px;"> <img src="https://ik.imagekit.io/w2okwbtu2/115727700_n9t8rrnwT.png?ik-sdk-version=javascript-1.4.3&updatedAt=1670872429989" width="57" border="0" class="" style="display: block; width: 100%;"></td>
</tr>
<tr class="nl2go-responsive-hide">
<td height="15" style="font-size: 15px; line-height: 15px;">­</td>
</tr>
</table>
</td>
</tr>
<tr>
<td class="r11-c" align="left">
<table cellspacing="0" cellpadding="0" border="0" role="presentation" width="100%" class="r12-o" style="table-layout: fixed; width: 100%;">
<tr>
<td align="center" valign="top" class="r21-i nl2go-default-textstyle" style="font-family: arial,helvetica,sans-serif; color: #3b3f44; font-size: 18px; line-height: 1.5; text-align: center;">
<div>
<p style="margin: 0; font-size: 14px;">Proudly made on <strong>Planet Earth 🌍</strong>.</p>
</div>
</td>
</tr>
</table>
</td>
</tr>
<tr>
<td class="r22-c" align="center">
<table cellspacing="0" cellpadding="0" border="0" role="presentation" width="570" class="r9-o" style="table-layout: fixed; width: 570px;">
<!-- -->
<tr>
<td valign="top" class="">
<table width="100%" cellspacing="0" cellpadding="0" border="0" role="presentation">
<tr>
<td class="r23-c" style="display: inline-block;">
<table cellspacing="0" cellpadding="0" border="0" role="presentation" width="570" class="r7-o" style="table-layout: fixed; width: 570px;">
<!-- -->
<tr class="nl2go-responsive-hide">
<td height="15" width="209" style="font-size: 15px; line-height: 15px;">­ </td>
<td height="15" style="font-size: 15px; line-height: 15px;">­</td>
<td height="15" width="209" style="font-size: 15px; line-height: 15px;">­ </td>
</tr>
<tr>
<td class="nl2go-responsive-hide" width="209" style="font-size: 0px; line-height: 1px;">­ </td>
<td class="r24-i">
<table width="100%" cellspacing="0" cellpadding="0" border="0" role="presentation">
<tr>
<th width="40" valign="" class="r25-c mobshow resp-table" style="font-weight: normal;">
<table cellspacing="0" cellpadding="0" border="0" role="presentation" width="100%" class="r26-o" style="table-layout: fixed; width: 100%;">
<!-- -->
<tr class="nl2go-responsive-hide">
<td height="5" style="font-size: 5px; line-height: 5px;">­</td>
<td height="5" width="8" style="font-size: 5px; line-height: 5px;">­ </td>
</tr>
<tr>
<td class="r27-i" style="font-size: 0px; line-height: 0px;"> <a href="https://github.com/makeplane/plane" target="_blank" style="color: #0092ff; text-decoration: underline;"> <img src="https://sendinblue-templates.s3.eu-west-3.amazonaws.com/icons/rounded_colored/github_32px.png" width="32" border="0" class="" style="display: block; width: 100%;"></a> </td>
<td class="nl2go-responsive-hide" width="8" style="font-size: 0px; line-height: 1px;">­ </td>
</tr>
<tr class="nl2go-responsive-hide">
<td height="5" style="font-size: 5px; line-height: 5px;">­</td>
<td height="5" width="8" style="font-size: 5px; line-height: 5px;">­ </td>
</tr>
</table>
</th>
<th width="40" valign="" class="r25-c mobshow resp-table" style="font-weight: normal;">
<table cellspacing="0" cellpadding="0" border="0" role="presentation" width="100%" class="r26-o" style="table-layout: fixed; width: 100%;">
<!-- -->
<tr class="nl2go-responsive-hide">
<td height="5" style="font-size: 5px; line-height: 5px;">­</td>
<td height="5" width="8" style="font-size: 5px; line-height: 5px;">­ </td>
</tr>
<tr>
<td class="r27-i" style="font-size: 0px; line-height: 0px;"> <a href="https://discord.gg/A92xrEGCge" target="_blank" style="color: #0092ff; text-decoration: underline;"> <img src="https://sendinblue-templates.s3.eu-west-3.amazonaws.com/icons/rounded_colored/discord_32px.png" width="32" border="0" class="" style="display: block; width: 100%;"></a> </td>
<td class="nl2go-responsive-hide" width="8" style="font-size: 0px; line-height: 1px;">­ </td>
</tr>
<tr class="nl2go-responsive-hide">
<td height="5" style="font-size: 5px; line-height: 5px;">­</td>
<td height="5" width="8" style="font-size: 5px; line-height: 5px;">­ </td>
</tr>
</table>
</th>
<th width="40" valign="" class="r25-c mobshow resp-table" style="font-weight: normal;">
<table cellspacing="0" cellpadding="0" border="0" role="presentation" width="100%" class="r26-o" style="table-layout: fixed; width: 100%;">
<!-- -->
<tr class="nl2go-responsive-hide">
<td height="5" style="font-size: 5px; line-height: 5px;">­</td>
<td height="5" width="8" style="font-size: 5px; line-height: 5px;">­ </td>
</tr>
<tr>
<td class="r27-i" style="font-size: 0px; line-height: 0px;"> <a href="https://twitter.com/planepowers" target="_blank" style="color: #0092ff; text-decoration: underline;"> <img src="https://sendinblue-templates.s3.eu-west-3.amazonaws.com/icons/rounded_colored/twitter_32px.png" width="32" border="0" class="" style="display: block; width: 100%;"></a> </td>
<td class="nl2go-responsive-hide" width="8" style="font-size: 0px; line-height: 1px;">­ </td>
</tr>
<tr class="nl2go-responsive-hide">
<td height="5" style="font-size: 5px; line-height: 5px;">­</td>
<td height="5" width="8" style="font-size: 5px; line-height: 5px;">­ </td>
</tr>
</table>
</th>
<th width="32" valign="" class="r25-c mobshow resp-table" style="font-weight: normal;">
<table cellspacing="0" cellpadding="0" border="0" role="presentation" width="100%" class="r28-o" style="table-layout: fixed; width: 100%;">
<!-- -->
<tr class="nl2go-responsive-hide">
<td height="5" style="font-size: 5px; line-height: 5px;">­</td>
</tr>
<tr>
<td class="r27-i" style="font-size: 0px; line-height: 0px;"> <a href="https://www.linkedin.com/company/planepowers/" target="_blank" style="color: #0092ff; text-decoration: underline;"> <img src="https://sendinblue-templates.s3.eu-west-3.amazonaws.com/icons/rounded_colored/linkedin_32px.png" width="32" border="0" class="" style="display: block; width: 100%;"></a> </td>
</tr>
<tr class="nl2go-responsive-hide">
<td height="5" style="font-size: 5px; line-height: 5px;">­</td>
</tr>
</table>
</th>
</tr>
</table>
</td>
<td class="nl2go-responsive-hide" width="209" style="font-size: 0px; line-height: 1px;">­ </td>
</tr>
<tr class="nl2go-responsive-hide">
<td height="15" width="209" style="font-size: 15px; line-height: 15px;">­ </td>
<td height="15" style="font-size: 15px; line-height: 15px;">­</td>
<td height="15" width="209" style="font-size: 15px; line-height: 15px;">­ </td>
</tr>
</table>
</td>
</tr>
</table>
</td>
</tr>
</table>
</td>
</tr>
</table>
</td>
<td class="nl2go-responsive-hide" width="15" style="font-size: 0px; line-height: 1px;">­ </td>
</tr>
</table>
</th>
</tr>
</table>
</td>
</tr>
<tr class="nl2go-responsive-hide">
<td height="20" style="font-size: 20px; line-height: 20px; background-color: #eff2f7;">­</td>
</tr>
</table>
</td>
</tr>
</table>
</td>
</tr>
</table>
</td>
</tr>
</table>
</body>
</html>

View File

@ -1,17 +1,19 @@
<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Strict//EN" "http://www.w3.org/TR/xhtml1/DTD/xhtml1-strict.dtd">
<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Strict//EN" "http://www.w3.org/TR/xhtml1/DTD/xhtml1-strict.dtd">
<html xmlns="http://www.w3.org/1999/xhtml" xmlns:v="urn:schemas-microsoft-com:vml" xmlns:o="urn:schemas-microsoft-com:office:office">
<head>
<meta http-equiv="Content-Type" content="text/html; charset=utf-8">
<meta http-equiv="X-UA-Compatible" content="IE=edge">
<meta name="format-detection" content="telephone=no">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title></title>
<title>Welcome to Plane ✈️!</title>
<style type="text/css" emogrify="no">#outlook a { padding:0; } .ExternalClass { width:100%; } .ExternalClass, .ExternalClass p, .ExternalClass span, .ExternalClass font, .ExternalClass td, .ExternalClass div { line-height: 100%; } table td { border-collapse: collapse; mso-line-height-rule: exactly; } .editable.image { font-size: 0 !important; line-height: 0 !important; } .nl2go_preheader { display: none !important; mso-hide:all !important; mso-line-height-rule: exactly; visibility: hidden !important; line-height: 0px !important; font-size: 0px !important; } body { width:100% !important; -webkit-text-size-adjust:100%; -ms-text-size-adjust:100%; margin:0; padding:0; } img { outline:none; text-decoration:none; -ms-interpolation-mode: bicubic; } a img { border:none; } table { border-collapse:collapse; mso-table-lspace:0pt; mso-table-rspace:0pt; } th { font-weight: normal; text-align: left; } *[class="gmail-fix"] { display: none !important; } </style>
<style type="text/css" emogrify="no"> @media (max-width: 600px) { .gmx-killpill { content: ' \03D1';} } </style>
<style type="text/css" emogrify="no">@media (max-width: 600px) { .gmx-killpill { content: ' \03D1';} .r0-c { box-sizing: border-box !important; text-align: center !important; valign: top !important; width: 320px !important } .r1-o { border-style: solid !important; margin: 0 auto 0 auto !important; width: 320px !important } .r2-c { box-sizing: border-box !important; text-align: center !important; valign: top !important; width: 100% !important } .r3-o { border-style: solid !important; margin: 0 auto 0 auto !important; width: 100% !important } .r4-i { background-color: #ffffff !important; padding-bottom: 20px !important; padding-left: 15px !important; padding-right: 15px !important; padding-top: 20px !important } .r5-c { box-sizing: border-box !important; display: block !important; valign: top !important; width: 100% !important } .r6-o { border-style: solid !important; width: 100% !important } .r7-i { padding-left: 0px !important; padding-right: 0px !important } .r8-c { box-sizing: border-box !important; text-align: center !important; valign: top !important; width: 200px !important } .r9-o { border-style: solid !important; margin: 0 auto 0 auto !important; width: 200px !important } .r10-i { padding-bottom: 15px !important; padding-top: 15px !important } .r11-i { background-color: #ffffff !important; padding-bottom: 0px !important; padding-left: 0px !important; padding-right: 0px !important; padding-top: 20px !important } .r12-c { box-sizing: border-box !important; text-align: left !important; valign: top !important; width: 100% !important } .r13-o { border-style: solid !important; margin: 0 auto 0 0 !important; width: 100% !important } .r14-i { padding-top: 15px !important; text-align: left !important } .r15-o { border-style: solid !important; margin: 0 auto 0 auto !important; margin-bottom: 15px !important; margin-top: 15px !important; width: 100% !important } .r16-i { text-align: center !important } .r17-r { background-color: #0092FF !important; border-radius: 4px !important; border-width: 0px !important; box-sizing: border-box; height: initial !important; padding-bottom: 12px !important; padding-left: 5px !important; padding-right: 5px !important; padding-top: 12px !important; text-align: center !important; width: 100% !important } .r18-i { background-color: #ffffff !important; padding-bottom: 20px !important; padding-left: 0px !important; padding-right: 0px !important; padding-top: 0px !important } .r19-i { padding-bottom: 15px !important; padding-top: 0px !important; text-align: left !important } .r20-i { background-color: #eff2f7 !important; padding-bottom: 20px !important; padding-left: 15px !important; padding-right: 15px !important; padding-top: 20px !important } .r21-i { padding-bottom: 0px !important; padding-top: 15px !important; text-align: center !important } .r22-i { padding-bottom: 0px !important; padding-top: 0px !important; text-align: center !important } .r23-i { padding-bottom: 15px !important; padding-top: 15px !important; text-align: center !important } .r24-c { box-sizing: border-box !important; text-align: center !important; width: 100% !important } .r25-i { padding-bottom: 15px !important; padding-left: 0px !important; padding-right: 0px !important; padding-top: 0px !important } .r26-c { box-sizing: border-box !important; text-align: center !important; valign: top !important; width: 129px !important } .r27-o { border-style: solid !important; margin: 0 auto 0 auto !important; width: 129px !important } body { -webkit-text-size-adjust: none } .nl2go-responsive-hide { display: none } .nl2go-body-table { min-width: unset !important } .mobshow { height: auto !important; overflow: visible !important; max-height: unset !important; visibility: visible !important; border: none !important } .resp-table { display: inline-table !important } .magic-resp { display: table-cell !important } } </style>
<style type="text/css" emogrify="no">@media (max-width: 600px) { .gmx-killpill { content: ' \03D1';} .r0-c { box-sizing: border-box !important; text-align: center !important; valign: top !important; width: 320px !important } .r1-o { border-style: solid !important; margin: 0 auto 0 auto !important; width: 320px !important } .r2-i { background-color: #ffffff !important } .r3-c { box-sizing: border-box !important; text-align: center !important; valign: top !important; width: 100% !important } .r4-o { border-style: solid !important; margin: 0 auto 0 auto !important; width: 100% !important } .r5-i { background-color: #f8f9fa !important; padding-bottom: 20px !important; padding-left: 10px !important; padding-right: 10px !important; padding-top: 20px !important } .r6-c { box-sizing: border-box !important; display: block !important; valign: top !important; width: 100% !important } .r7-o { border-style: solid !important; width: 100% !important } .r8-i { padding-left: 0px !important; padding-right: 0px !important } .r9-i { padding-bottom: 15px !important; padding-top: 15px !important } .r10-c { box-sizing: border-box !important; text-align: left !important; valign: top !important; width: 100% !important } .r11-o { border-style: solid !important; margin: 0 auto 0 0 !important; width: 100% !important } .r12-i { padding-left: 20px !important; padding-right: 20px !important; padding-top: 0px !important; text-align: left !important } .r13-i { padding-bottom: 15px !important; padding-left: 20px !important; padding-right: 20px !important; padding-top: 15px !important; text-align: left !important } .r14-o { border-style: solid !important; margin: 0 auto 0 auto !important; margin-bottom: 15px !important; margin-top: 15px !important; width: 100% !important } .r15-i { text-align: center !important } .r16-r { background-color: #ffffff !important; border-color: #3f76ff !important; border-radius: 4px !important; border-width: 1px !important; box-sizing: border-box; height: initial !important; padding-bottom: 7px !important; padding-left: 5px !important; padding-right: 5px !important; padding-top: 7px !important; text-align: center !important; width: 100% !important } .r17-r { background-color: #ffffff !important; border-color: #000000 !important; border-radius: 4px !important; border-width: 1px !important; box-sizing: border-box; height: initial !important; padding-bottom: 7px !important; padding-left: 5px !important; padding-right: 5px !important; padding-top: 7px !important; text-align: center !important; width: 100% !important } .r18-i { background-color: #eff2f7 !important; padding-bottom: 20px !important; padding-left: 15px !important; padding-right: 15px !important; padding-top: 20px !important } .r19-i { color: #3b3f44 !important; padding-bottom: 0px !important; padding-top: 0px !important; text-align: center !important } .r20-c { box-sizing: border-box !important; text-align: center !important; width: 100% !important } .r21-c { box-sizing: border-box !important; width: 100% !important } .r22-i { font-size: 0px !important; padding-bottom: 15px !important; padding-left: 65px !important; padding-right: 65px !important; padding-top: 15px !important } .r23-c { box-sizing: border-box !important; width: 32px !important } .r24-o { border-style: solid !important; margin-right: 8px !important; width: 32px !important } .r25-i { padding-bottom: 5px !important; padding-top: 5px !important } .r26-o { border-style: solid !important; margin-right: 0px !important; width: 32px !important } .r27-i { color: #3b3f44 !important; padding-bottom: 15px !important; padding-top: 15px !important; text-align: center !important } .r28-i { padding-bottom: 15px !important; padding-left: 0px !important; padding-right: 0px !important; padding-top: 0px !important } .r29-c { box-sizing: border-box !important; text-align: center !important; valign: top !important; width: 129px !important } .r30-o { border-style: solid !important; margin: 0 auto 0 auto !important; width: 129px !important } body { -webkit-text-size-adjust: none } .nl2go-responsive-hide { display: none } .nl2go-body-table { min-width: unset !important } .mobshow { height: auto !important; overflow: visible !important; max-height: unset !important; visibility: visible !important; border: none !important } .resp-table { display: inline-table !important } .magic-resp { display: table-cell !important } } </style>
<!--[if !mso]><!-->
<style type="text/css" emogrify="no">@import url("https://fonts.googleapis.com/css2?family=Bitter&family=Roboto"); </style>
<!--<![endif]-->
<style type="text/css">p, h1, h2, h3, h4, ol, ul { margin: 0; } a, a:link { color: #0092ff; text-decoration: underline } .nl2go-default-textstyle { color: #3b3f44; font-family: arial,helvetica,sans-serif; font-size: 16px; line-height: 1.5 } .default-button { border-radius: 4px; color: #ffffff; font-family: arial,helvetica,sans-serif; font-size: 16px; font-style: normal; font-weight: normal; line-height: 1.15; text-decoration: none; width: 50% } .default-heading1 { color: #1F2D3D; font-family: arial,helvetica,sans-serif; font-size: 36px } .default-heading2 { color: #1F2D3D; font-family: arial,helvetica,sans-serif; font-size: 32px } .default-heading3 { color: #1F2D3D; font-family: arial,helvetica,sans-serif; font-size: 24px } .default-heading4 { color: #1F2D3D; font-family: arial,helvetica,sans-serif; font-size: 18px } a[x-apple-data-detectors] { color: inherit !important; text-decoration: inherit !important; font-size: inherit !important; font-family: inherit !important; font-weight: inherit !important; line-height: inherit !important; } .no-show-for-you { border: none; display: none; float: none; font-size: 0; height: 0; line-height: 0; max-height: 0; mso-hide: all; overflow: hidden; table-layout: fixed; visibility: hidden; width: 0; } </style>
<style type="text/css">p, h1, h2, h3, h4, ol, ul { margin: 0; } a, a:link { color: #0092ff; text-decoration: underline } .nl2go-default-textstyle { color: #3b3f44; font-family: arial,helvetica,sans-serif; font-size: 16px; line-height: 1.5 } .default-button { border-radius: 4px; color: #ffffff; font-family: arial,helvetica,sans-serif; font-size: 16px; font-style: normal; font-weight: bold; line-height: 1.15; text-decoration: none; width: 50% } .default-heading1 { color: #1F2D3D; font-family: arial,helvetica,sans-serif; font-size: 36px } .default-heading2 { color: #1F2D3D; font-family: arial,helvetica,sans-serif; font-size: 32px } .default-heading3 { color: #1F2D3D; font-family: arial,helvetica,sans-serif; font-size: 24px } .default-heading4 { color: #1F2D3D; font-family: arial,helvetica,sans-serif; font-size: 18px } a[x-apple-data-detectors] { color: inherit !important; text-decoration: inherit !important; font-size: inherit !important; font-family: inherit !important; font-weight: inherit !important; line-height: inherit !important; } .no-show-for-you { border: none; display: none; float: none; font-size: 0; height: 0; line-height: 0; max-height: 0; mso-hide: all; overflow: hidden; table-layout: fixed; visibility: hidden; width: 0; } </style>
<!--[if mso]>
<xml>
<o:OfficeDocumentSettings>
@ -22,179 +24,161 @@
<![endif]-->
<style type="text/css">a:link{color: #0092ff; text-decoration: underline;}</style>
</head>
<body text="#3b3f44" link="#0092ff" yahoo="fix" style="">
<table cellspacing="0" cellpadding="0" border="0" role="presentation" class="nl2go-body-table" width="100%" style="width: 100%;">
<body bgcolor="#ffffff" text="#3b3f44" link="#0092ff" yahoo="fix" style="background-color: #ffffff; padding-bottom: 0px; padding-top: 0px;">
<table cellspacing="0" cellpadding="0" border="0" role="presentation" class="nl2go-body-table" width="100%" style="background-color: #ffffff; width: 100%;">
<tr>
<td align="center" class="r0-c">
<table cellspacing="0" cellpadding="0" border="0" role="presentation" width="600" class="r1-o" style="table-layout: fixed; width: 600px;">
<tr>
<td valign="top" class="">
<td valign="top" class="r2-i" style="background-color: #ffffff;">
<table width="100%" cellspacing="0" cellpadding="0" border="0" role="presentation">
<tr>
<td class="r2-c" align="center">
<table cellspacing="0" cellpadding="0" border="0" role="presentation" width="100%" class="r3-o" style="table-layout: fixed; width: 100%;">
<td class="r3-c" align="center">
<table cellspacing="0" cellpadding="0" border="0" role="presentation" width="100%" class="r4-o" style="table-layout: fixed; width: 100%;">
<!-- -->
<tr class="nl2go-responsive-hide">
<td height="20" style="font-size: 20px; line-height: 20px; background-color: #ffffff;">­</td>
<td height="20" style="font-size: 20px; line-height: 20px; background-color: #f8f9fa;">­</td>
</tr>
<tr>
<td class="r4-i" style="background-color: #ffffff;">
<td class="r5-i" style="background-color: #f8f9fa;">
<table width="100%" cellspacing="0" cellpadding="0" border="0" role="presentation">
<tr>
<th width="100%" valign="top" class="r5-c" style="font-weight: normal;">
<table cellspacing="0" cellpadding="0" border="0" role="presentation" width="100%" class="r6-o" style="table-layout: fixed; width: 100%;">
<th width="100%" valign="top" class="r6-c" style="font-weight: normal;">
<table cellspacing="0" cellpadding="0" border="0" role="presentation" width="100%" class="r7-o" style="table-layout: fixed; width: 100%;">
<!-- -->
<tr>
<td class="nl2go-responsive-hide" width="15" style="font-size: 0px; line-height: 1px;">­ </td>
<td valign="top" class="r7-i">
<td valign="top" class="r8-i">
<table width="100%" cellspacing="0" cellpadding="0" border="0" role="presentation">
<tr>
<td class="r8-c" align="center">
<table cellspacing="0" cellpadding="0" border="0" role="presentation" width="200" class="r9-o" style="table-layout: fixed; width: 200px;">
<td class="r3-c" align="center">
<table cellspacing="0" cellpadding="0" border="0" role="presentation" width="120" class="r4-o" style="table-layout: fixed; width: 120px;">
<tr class="nl2go-responsive-hide">
<td height="15" style="font-size: 15px; line-height: 15px;">­</td>
</tr>
<tr>
<td class="r9-i" style="font-size: 0px; line-height: 0px;"> <img src="https://ik.imagekit.io/w2okwbtu2/Plane_Logo_pIhtbyIoa.png?ik-sdk-version=javascript-1.4.3&updatedAt=1670873447444" width="120" border="0" class="" style="display: block; width: 100%;"></td>
</tr>
<tr class="nl2go-responsive-hide">
<td height="15" style="font-size: 15px; line-height: 15px;">­</td>
</tr>
</table>
</td>
</tr>
</table>
</td>
<td class="nl2go-responsive-hide" width="15" style="font-size: 0px; line-height: 1px;">­ </td>
</tr>
</table>
</th>
</tr>
</table>
</td>
</tr>
<tr class="nl2go-responsive-hide">
<td height="20" style="font-size: 20px; line-height: 20px; background-color: #ffffff;">­</td>
</tr>
</table>
</td>
</tr>
<tr>
<td class="r2-c" align="center">
<table cellspacing="0" cellpadding="0" border="0" role="presentation" width="100%" class="r3-o" style="table-layout: fixed; width: 100%;">
<!-- -->
<tr class="nl2go-responsive-hide">
<td height="20" style="font-size: 20px; line-height: 20px; background-color: #ffffff;">­</td>
</tr>
<tr>
<td class="r11-i" style="background-color: #ffffff;">
<table width="100%" cellspacing="0" cellpadding="0" border="0" role="presentation">
<tr>
<th width="100%" valign="top" class="r5-c" style="font-weight: normal;">
<table cellspacing="0" cellpadding="0" border="0" role="presentation" width="100%" class="r6-o" style="table-layout: fixed; width: 100%;">
<!-- -->
<tr>
<td class="nl2go-responsive-hide" width="15" style="font-size: 0px; line-height: 1px;">­ </td>
<td valign="top" class="r7-i">
<table width="100%" cellspacing="0" cellpadding="0" border="0" role="presentation">
<tr>
<td class="r12-c" align="left">
<table cellspacing="0" cellpadding="0" border="0" role="presentation" width="100%" class="r13-o" style="table-layout: fixed; width: 100%;">
<td class="r10-c" align="left">
<table cellspacing="0" cellpadding="0" border="0" role="presentation" width="100%" class="r11-o" style="table-layout: fixed; width: 100%;">
<tr>
<td class="nl2go-responsive-hide" width="20" style="font-size: 0px; line-height: 1px;">­ </td>
<td align="left" valign="top" class="r12-i nl2go-default-textstyle" style="color: #3b3f44; font-family: arial,helvetica,sans-serif; font-size: 16px; line-height: 1.5; text-align: left;">
<div>
<h3 class="default-heading3" style="margin: 0; color: #1f2d3d; font-family: arial,helvetica,sans-serif; font-size: 24px;">Welcome to Plane!</h3>
</div>
</td>
<td class="nl2go-responsive-hide" width="20" style="font-size: 0px; line-height: 1px;">­ </td>
</tr>
</table>
</td>
</tr>
<tr>
<td class="r10-c" align="left">
<table cellspacing="0" cellpadding="0" border="0" role="presentation" width="100%" class="r11-o" style="table-layout: fixed; width: 100%;">
<tr class="nl2go-responsive-hide">
<td height="15" width="20" style="font-size: 15px; line-height: 15px;">­ </td>
<td height="15" style="font-size: 15px; line-height: 15px;">­</td>
<td height="15" width="20" style="font-size: 15px; line-height: 15px;">­ </td>
</tr>
<tr>
<td align="left" valign="top" class="r14-i nl2go-default-textstyle" style="color: #3b3f44; font-family: arial,helvetica,sans-serif; font-size: 16px; line-height: 1.5; text-align: left;">
<td class="nl2go-responsive-hide" width="20" style="font-size: 0px; line-height: 1px;">­ </td>
<td align="left" valign="top" class="r13-i nl2go-default-textstyle" style="color: #3b3f44; font-family: arial,helvetica,sans-serif; font-size: 16px; line-height: 1.5; text-align: left;">
<div>
<p style="margin: 0;">Hi,</p>
<p style="margin: 0;">We're thrilled you're here. We know this is the beginning of a long and exciting<br>journey, and we want to be there every step of the way.</p>
</div>
</td>
<td class="nl2go-responsive-hide" width="20" style="font-size: 0px; line-height: 1px;">­ </td>
</tr>
<tr class="nl2go-responsive-hide">
<td height="15" width="20" style="font-size: 15px; line-height: 15px;">­ </td>
<td height="15" style="font-size: 15px; line-height: 15px;">­</td>
<td height="15" width="20" style="font-size: 15px; line-height: 15px;">­ </td>
</tr>
</table>
</td>
</tr>
<tr>
<td class="r10-c" align="left">
<table cellspacing="0" cellpadding="0" border="0" role="presentation" width="100%" class="r11-o" style="table-layout: fixed; width: 100%;">
<tr class="nl2go-responsive-hide">
<td height="15" width="20" style="font-size: 15px; line-height: 15px;">­ </td>
<td height="15" style="font-size: 15px; line-height: 15px;">­</td>
<td height="15" width="20" style="font-size: 15px; line-height: 15px;">­ </td>
</tr>
<tr>
<td class="nl2go-responsive-hide" width="20" style="font-size: 0px; line-height: 1px;">­ </td>
<td align="left" valign="top" class="r13-i nl2go-default-textstyle" style="color: #3b3f44; font-family: arial,helvetica,sans-serif; font-size: 16px; line-height: 1.5; text-align: left;">
<div>
<p style="margin: 0;"><strong>Plane is an open-source issue planning and tracking tool</strong> that allows teams to collaborate on projects and prioritize tasks. With Plane, you can easily create and assign issues, set deadlines, and track progress.</p>
</div>
</td>
<td class="nl2go-responsive-hide" width="20" style="font-size: 0px; line-height: 1px;">­ </td>
</tr>
<tr class="nl2go-responsive-hide">
<td height="15" width="20" style="font-size: 15px; line-height: 15px;">­ </td>
<td height="15" style="font-size: 15px; line-height: 15px;">­</td>
<td height="15" width="20" style="font-size: 15px; line-height: 15px;">­ </td>
</tr>
</table>
</td>
</tr>
<tr>
<td class="r10-c" align="left">
<table cellspacing="0" cellpadding="0" border="0" role="presentation" width="100%" class="r11-o" style="table-layout: fixed; width: 100%;">
<tr class="nl2go-responsive-hide">
<td height="15" width="20" style="font-size: 15px; line-height: 15px;">­ </td>
<td height="15" style="font-size: 15px; line-height: 15px;">­</td>
<td height="15" width="20" style="font-size: 15px; line-height: 15px;">­ </td>
</tr>
<tr>
<td class="nl2go-responsive-hide" width="20" style="font-size: 0px; line-height: 1px;">­ </td>
<td align="left" valign="top" class="r13-i nl2go-default-textstyle" style="color: #3b3f44; font-family: arial,helvetica,sans-serif; font-size: 16px; line-height: 1.5; text-align: left;">
<div>
<p style="margin: 0;">We have put together some resources to help you get started. Please find them below:</p>
<p style="margin: 0;"> </p>
<p style="margin: 0;">Welcome to Plane! We're thrilled you're here. We know this is the beginning of a long and exciting journey, and we want to be there every step of the way.</p>
<ul style="margin: 0; margin-top:20px;">
<li><a href="https://docs.plane.so/get-started" target="_blank" style="color: #0092ff; text-decoration: underline;">Getting started with Plane</a></li>
<li><a href="https://plane.so/changelog" target="_blank" style="color: #0092ff; text-decoration: underline;">Plane Changelog</a></li>
</ul>
</div>
</td>
<td class="nl2go-responsive-hide" width="20" style="font-size: 0px; line-height: 1px;">­ </td>
</tr>
<tr class="nl2go-responsive-hide">
<td height="15" width="20" style="font-size: 15px; line-height: 15px;">­ </td>
<td height="15" style="font-size: 15px; line-height: 15px;">­</td>
<td height="15" width="20" style="font-size: 15px; line-height: 15px;">­ </td>
</tr>
</table>
</td>
</tr>
<tr>
<td class="r12-c" align="left">
<table cellspacing="0" cellpadding="0" border="0" role="presentation" width="100%" class="r13-o" style="table-layout: fixed; width: 100%;">
<td class="r3-c" align="center">
<table cellspacing="0" cellpadding="0" border="0" role="presentation" width="300" class="r14-o" style="table-layout: fixed; width: 300px;">
<tr class="nl2go-responsive-hide">
<td height="15" style="font-size: 15px; line-height: 15px;">­</td>
</tr>
<tr>
<td align="left" valign="top" class="r14-i nl2go-default-textstyle" style="color: #3b3f44; font-family: arial,helvetica,sans-serif; font-size: 16px; line-height: 1.5; text-align: left;">
<div>
<p style="margin: 0;">To get the most out of Plane, read the quick start guide from our documentation.</p>
</div>
</td>
</tr>
</table>
</td>
</tr>
<tr>
<td class="r12-c" align="left">
<table cellspacing="0" cellpadding="0" border="0" role="presentation" width="100%" class="r13-o" style="table-layout: fixed; width: 100%;">
<tr class="nl2go-responsive-hide">
<td height="15" style="font-size: 15px; line-height: 15px;">­</td>
</tr>
<tr>
<td align="left" valign="top" class="r14-i nl2go-default-textstyle" style="color: #3b3f44; font-family: arial,helvetica,sans-serif; font-size: 16px; line-height: 1.5; text-align: left;">
<div>
<p style="margin: 0;"><i><strong>Plane is open-source—it would mean a lot if you gave us a star on GitHub.</strong></i></p>
</div>
</td>
</tr>
</table>
</td>
</tr>
</table>
</td>
<td class="nl2go-responsive-hide" width="15" style="font-size: 0px; line-height: 1px;">­ </td>
</tr>
</table>
</th>
</tr>
</table>
</td>
</tr>
</table>
</td>
</tr>
<tr>
<td class="r2-c" align="center">
<table cellspacing="0" cellpadding="0" border="0" role="presentation" width="100%" class="r3-o" style="table-layout: fixed; width: 100%;">
<!-- -->
<tr class="nl2go-responsive-hide">
<td height="20" style="font-size: 20px; line-height: 20px; background-color: #ffffff;">­</td>
</tr>
<tr>
<td class="r4-i" style="background-color: #ffffff;">
<table width="100%" cellspacing="0" cellpadding="0" border="0" role="presentation">
<tr>
<th width="100%" valign="top" class="r5-c" style="font-weight: normal;">
<table cellspacing="0" cellpadding="0" border="0" role="presentation" width="100%" class="r6-o" style="table-layout: fixed; width: 100%;">
<!-- -->
<tr>
<td class="nl2go-responsive-hide" width="15" style="font-size: 0px; line-height: 1px;">­ </td>
<td valign="top" class="r7-i">
<table width="100%" cellspacing="0" cellpadding="0" border="0" role="presentation">
<tr>
<td class="r2-c" align="center">
<table cellspacing="0" cellpadding="0" border="0" role="presentation" width="285" class="r15-o" style="table-layout: fixed; width: 285px;">
<tr class="nl2go-responsive-hide">
<td height="15" style="font-size: 15px; line-height: 15px;">­</td>
</tr>
<tr>
<td height="18" align="center" valign="top" class="r16-i nl2go-default-textstyle" style="color: #3b3f44; font-family: arial,helvetica,sans-serif; font-size: 16px; line-height: 1.5;">
<td height="18" align="center" valign="top" class="r15-i nl2go-default-textstyle" style="color: #3b3f44; font-family: arial,helvetica,sans-serif; font-size: 16px; line-height: 1.5;">
<!--[if mso]>
<v:roundrect xmlns:v="urn:schemas-microsoft-com:vml" xmlns:w="urn:schemas-microsoft-com:office:word" href="https://github.com/makeplane" style="v-text-anchor:middle; height: 41px; width: 284px;" arcsize="10%" fillcolor="#0092FF" strokecolor="#0092FF" strokeweight="1px" data-btn="1">
<v:roundrect xmlns:v="urn:schemas-microsoft-com:vml" xmlns:w="urn:schemas-microsoft-com:office:word" href="https://app.plane.so/" style="v-text-anchor:middle; height: 33px; width: 301px;" arcsize="12%" fillcolor="#ffffff" strokecolor="#3f76ff" strokeweight="1px" data-btn="1">
<w:anchorlock/>
<div style="display:none;">
<center class="default-button">
<p>Star us GitHub</p>
<p><span style="color:#3F76FF;font-size:14px;">Open Plane</span></p>
</center>
</div>
</v:roundrect>
<![endif]--> <!--[if !mso]><!-- -->
<a href="https://github.com/makeplane" class="r17-r default-button" target="_blank" data-btn="1" style="font-style: normal; font-weight: normal; line-height: 1.15; text-decoration: none; border-style: solid; word-wrap: break-word; display: inline-block; -webkit-text-size-adjust: none; mso-hide: all; background-color: #0092FF; border-color: #0092FF; border-radius: 4px; border-width: 0px; color: #ffffff; font-family: arial,helvetica,sans-serif; font-size: 16px; height: 18px; padding-bottom: 12px; padding-left: 5px; padding-right: 5px; padding-top: 12px; width: 275px;">
<p style="margin: 0;">Star us GitHub</p>
<a href="https://app.plane.so/" class="r16-r default-button" target="_blank" data-btn="1" style="font-style: normal; font-weight: bold; line-height: 1.15; text-decoration: none; border-style: solid; word-wrap: break-word; display: inline-block; -webkit-text-size-adjust: none; mso-hide: all; background-color: #ffffff; border-color: #3f76ff; border-radius: 4px; border-width: 1px; color: #ffffff; font-family: arial,helvetica,sans-serif; font-size: 16px; height: 18px; padding-bottom: 7px; padding-left: 5px; padding-right: 5px; padding-top: 7px; width: 288px;">
<p style="margin: 0;"><span style="color: #3F76FF; font-size: 14px;">Open Plane</span></p>
</a>
<!--<![endif]-->
</td>
@ -205,73 +189,51 @@
</table>
</td>
</tr>
</table>
</td>
<td class="nl2go-responsive-hide" width="15" style="font-size: 0px; line-height: 1px;">­ </td>
</tr>
</table>
</th>
</tr>
</table>
</td>
</tr>
<tr class="nl2go-responsive-hide">
<td height="20" style="font-size: 20px; line-height: 20px; background-color: #ffffff;">­</td>
</tr>
</table>
</td>
</tr>
<tr>
<td class="r2-c" align="center">
<table cellspacing="0" cellpadding="0" border="0" role="presentation" width="100%" class="r3-o" style="table-layout: fixed; width: 100%;">
<!-- -->
<tr>
<td class="r18-i" style="background-color: #ffffff;">
<table width="100%" cellspacing="0" cellpadding="0" border="0" role="presentation">
<tr>
<th width="100%" valign="top" class="r5-c" style="font-weight: normal;">
<table cellspacing="0" cellpadding="0" border="0" role="presentation" width="100%" class="r6-o" style="table-layout: fixed; width: 100%;">
<!-- -->
<tr>
<td class="nl2go-responsive-hide" width="10" style="font-size: 0px; line-height: 1px;">­ </td>
<td valign="top" class="r7-i">
<table width="100%" cellspacing="0" cellpadding="0" border="0" role="presentation">
<tr>
<td class="r12-c" align="left">
<table cellspacing="0" cellpadding="0" border="0" role="presentation" width="100%" class="r13-o" style="table-layout: fixed; width: 100%;">
<td class="r10-c" align="left">
<table cellspacing="0" cellpadding="0" border="0" role="presentation" width="100%" class="r11-o" style="table-layout: fixed; width: 100%;">
<tr class="nl2go-responsive-hide">
<td height="15" width="20" style="font-size: 15px; line-height: 15px;">­ </td>
<td height="15" style="font-size: 15px; line-height: 15px;">­</td>
<td height="15" width="20" style="font-size: 15px; line-height: 15px;">­ </td>
</tr>
<tr>
<td align="left" valign="top" class="r19-i nl2go-default-textstyle" style="color: #3b3f44; font-family: arial,helvetica,sans-serif; font-size: 16px; line-height: 1.5; text-align: left;">
<td class="nl2go-responsive-hide" width="20" style="font-size: 0px; line-height: 1px;">­ </td>
<td align="left" valign="top" class="r13-i nl2go-default-textstyle" style="color: #3b3f44; font-family: arial,helvetica,sans-serif; font-size: 16px; line-height: 1.5; text-align: left;">
<div>
<p style="margin: 0;">If you have any questions or need help, reply to this email or on <a href="https://twitter.com/planepowers" target="_blank" style="color: #0092ff; text-decoration: underline;">Twitter</a>. Join us on <a href="https://discord.com/invite/8SR2N9PAcJ" target="_blank" style="color: #0092ff; text-decoration: underline;">Discord</a> to ask questions and meet other software creators using Plane.</p>
<p style="margin: 0;">Also, if you like Plane, please consider starring us on GitHub. This helps us to grow our community and make Plane even better.</p>
</div>
</td>
<td class="nl2go-responsive-hide" width="20" style="font-size: 0px; line-height: 1px;">­ </td>
</tr>
<tr class="nl2go-responsive-hide">
<td height="15" width="20" style="font-size: 15px; line-height: 15px;">­ </td>
<td height="15" style="font-size: 15px; line-height: 15px;">­</td>
<td height="15" width="20" style="font-size: 15px; line-height: 15px;">­ </td>
</tr>
</table>
</td>
</tr>
<tr>
<td class="r2-c" align="center">
<table cellspacing="0" cellpadding="0" border="0" role="presentation" width="290" class="r15-o" style="table-layout: fixed; width: 290px;">
<td class="r3-c" align="center">
<table cellspacing="0" cellpadding="0" border="0" role="presentation" width="300" class="r14-o" style="table-layout: fixed; width: 300px;">
<tr class="nl2go-responsive-hide">
<td height="15" style="font-size: 15px; line-height: 15px;">­</td>
</tr>
<tr>
<td height="18" align="center" valign="top" class="r16-i nl2go-default-textstyle" style="color: #3b3f44; font-family: arial,helvetica,sans-serif; font-size: 16px; line-height: 1.5;">
<td height="18" align="center" valign="top" class="r15-i nl2go-default-textstyle" style="color: #3b3f44; font-family: arial,helvetica,sans-serif; font-size: 16px; line-height: 1.5;">
<!--[if mso]>
<v:roundrect xmlns:v="urn:schemas-microsoft-com:vml" xmlns:w="urn:schemas-microsoft-com:office:word" href="https://app.plane.so/" style="v-text-anchor:middle; height: 41px; width: 289px;" arcsize="10%" fillcolor="#0092FF" strokecolor="#0092FF" strokeweight="1px" data-btn="2">
<v:roundrect xmlns:v="urn:schemas-microsoft-com:vml" xmlns:w="urn:schemas-microsoft-com:office:word" href="https://github.com/makeplane/plane" style="v-text-anchor:middle; height: 33px; width: 301px;" arcsize="12%" fillcolor="#ffffff" strokecolor="#000000" strokeweight="1px" data-btn="2">
<w:anchorlock/>
<div style="display:none;">
<center class="default-button">
<p>Plan-everything-ing now!&nbsp;</p>
<p><span style="color:#000000;font-size:14px;">⭐ Star us on GitHub</span></p>
</center>
</div>
</v:roundrect>
<![endif]--> <!--[if !mso]><!-- -->
<a href="https://app.plane.so/" class="r17-r default-button" target="_blank" data-btn="2" style="font-style: normal; font-weight: normal; line-height: 1.15; text-decoration: none; border-style: solid; word-wrap: break-word; display: inline-block; -webkit-text-size-adjust: none; mso-hide: all; background-color: #0092FF; border-color: #0092FF; border-radius: 4px; border-width: 0px; color: #ffffff; font-family: arial,helvetica,sans-serif; font-size: 16px; height: 18px; padding-bottom: 12px; padding-left: 5px; padding-right: 5px; padding-top: 12px; width: 280px;">
<p style="margin: 0;">Plan-everything-ing now! </p>
<a href="https://github.com/makeplane/plane" class="r17-r default-button" target="_blank" data-btn="2" style="font-style: normal; font-weight: bold; line-height: 1.15; text-decoration: none; border-style: solid; word-wrap: break-word; display: inline-block; -webkit-text-size-adjust: none; mso-hide: all; background-color: #ffffff; border-color: #000000; border-radius: 4px; border-width: 1px; color: #ffffff; font-family: arial,helvetica,sans-serif; font-size: 16px; height: 18px; padding-bottom: 7px; padding-left: 5px; padding-right: 5px; padding-top: 7px; width: 288px;">
<p style="margin: 0;"><span style="color: #000000; font-size: 14px;">⭐ Star us on GitHub</span></p>
</a>
<!--<![endif]-->
</td>
@ -282,9 +244,33 @@
</table>
</td>
</tr>
<tr>
<td class="r10-c" align="left">
<table cellspacing="0" cellpadding="0" border="0" role="presentation" width="100%" class="r11-o" style="table-layout: fixed; width: 100%;">
<tr class="nl2go-responsive-hide">
<td height="15" width="20" style="font-size: 15px; line-height: 15px;">­ </td>
<td height="15" style="font-size: 15px; line-height: 15px;">­</td>
<td height="15" width="20" style="font-size: 15px; line-height: 15px;">­ </td>
</tr>
<tr>
<td class="nl2go-responsive-hide" width="20" style="font-size: 0px; line-height: 1px;">­ </td>
<td align="left" valign="top" class="r13-i nl2go-default-textstyle" style="color: #3b3f44; font-family: arial,helvetica,sans-serif; font-size: 16px; line-height: 1.5; text-align: left;">
<div>
<p style="margin: 0;"><span style="font-size: 12px;">Note: 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 </span><a href="https://discord.gg/A92xrEGCge" target="_blank" style="color: #0092ff; text-decoration: underline;"><span style="font-size: 12px;">Discord</span></a><span style="font-size: 12px;"> or </span><a href="https://github.com/makeplane/plane" target="_blank" style="color: #0092ff; text-decoration: underline;"><span style="font-size: 12px;">GitHub</span></a><span style="font-size: 12px;">, and we will use your feedback to improve on our upcoming releases.</span></p>
</div>
</td>
<td class="nl2go-responsive-hide" width="20" style="font-size: 0px; line-height: 1px;">­ </td>
</tr>
<tr class="nl2go-responsive-hide">
<td height="15" width="20" style="font-size: 15px; line-height: 15px;">­ </td>
<td height="15" style="font-size: 15px; line-height: 15px;">­</td>
<td height="15" width="20" style="font-size: 15px; line-height: 15px;">­ </td>
</tr>
</table>
</td>
</tr>
</table>
</td>
<td class="nl2go-responsive-hide" width="10" style="font-size: 0px; line-height: 1px;">­ </td>
</tr>
</table>
</th>
@ -293,39 +279,51 @@
</td>
</tr>
<tr class="nl2go-responsive-hide">
<td height="20" style="font-size: 20px; line-height: 20px; background-color: #ffffff;">­</td>
<td height="20" style="font-size: 20px; line-height: 20px; background-color: #f8f9fa;">­</td>
</tr>
</table>
</td>
</tr>
<tr>
<td class="r2-c" align="center">
<table cellspacing="0" cellpadding="0" border="0" role="presentation" width="100%" class="r3-o" style="table-layout: fixed; width: 100%;">
<td class="r3-c" align="center">
<table cellspacing="0" cellpadding="0" border="0" role="presentation" width="100%" class="r4-o" style="table-layout: fixed; width: 100%;">
<!-- -->
<tr class="nl2go-responsive-hide">
<td height="20" style="font-size: 20px; line-height: 20px; background-color: #eff2f7;">­</td>
</tr>
<tr>
<td class="r20-i" style="background-color: #eff2f7;">
<td class="r18-i" style="background-color: #eff2f7;">
<table width="100%" cellspacing="0" cellpadding="0" border="0" role="presentation">
<tr>
<th width="100%" valign="top" class="r5-c" style="font-weight: normal;">
<table cellspacing="0" cellpadding="0" border="0" role="presentation" width="100%" class="r6-o" style="table-layout: fixed; width: 100%;">
<th width="100%" valign="top" class="r6-c" style="font-weight: normal;">
<table cellspacing="0" cellpadding="0" border="0" role="presentation" width="100%" class="r7-o" style="table-layout: fixed; width: 100%;">
<!-- -->
<tr>
<td class="nl2go-responsive-hide" width="15" style="font-size: 0px; line-height: 1px;">­ </td>
<td valign="top" class="r7-i">
<td valign="top" class="r8-i">
<table width="100%" cellspacing="0" cellpadding="0" border="0" role="presentation">
<tr>
<td class="r12-c" align="left">
<table cellspacing="0" cellpadding="0" border="0" role="presentation" width="100%" class="r13-o" style="table-layout: fixed; width: 100%;">
<td class="r3-c" align="center">
<table cellspacing="0" cellpadding="0" border="0" role="presentation" width="57" class="r4-o" style="table-layout: fixed; width: 57px;">
<tr class="nl2go-responsive-hide">
<td height="15" style="font-size: 15px; line-height: 15px;">­</td>
</tr>
<tr>
<td align="center" valign="top" class="r21-i nl2go-default-textstyle" style="color: #3b3f44; font-family: arial,helvetica,sans-serif; font-size: 18px; line-height: 1.5; text-align: center;">
<td class="r9-i" style="font-size: 0px; line-height: 0px;"> <img src="https://ik.imagekit.io/w2okwbtu2/115727700_n9t8rrnwT.png?ik-sdk-version=javascript-1.4.3&updatedAt=1670872429989" width="57" border="0" class="" style="display: block; width: 100%;"></td>
</tr>
<tr class="nl2go-responsive-hide">
<td height="15" style="font-size: 15px; line-height: 15px;">­</td>
</tr>
</table>
</td>
</tr>
<tr>
<td class="r10-c" align="left">
<table cellspacing="0" cellpadding="0" border="0" role="presentation" width="100%" class="r11-o" style="table-layout: fixed; width: 100%;">
<tr>
<td align="center" valign="top" class="r19-i nl2go-default-textstyle" style="font-family: arial,helvetica,sans-serif; color: #3b3f44; font-size: 18px; line-height: 1.5; text-align: center;">
<div>
<p style="margin: 0;">Plane</p>
<p style="margin: 0; font-size: 14px;">Proudly made on <strong>Planet Earth 🌍</strong>.</p>
</div>
</td>
</tr>
@ -333,44 +331,120 @@
</td>
</tr>
<tr>
<td class="r12-c" align="left">
<table cellspacing="0" cellpadding="0" border="0" role="presentation" width="100%" class="r13-o" style="table-layout: fixed; width: 100%;">
<tr class="nl2go-responsive-hide">
<td height="15" style="font-size: 15px; line-height: 15px;">­</td>
</tr>
<td class="r20-c" align="center">
<table cellspacing="0" cellpadding="0" border="0" role="presentation" width="570" class="r4-o" style="table-layout: fixed; width: 570px;">
<!-- -->
<tr>
<td align="center" valign="top" class="r21-i nl2go-default-textstyle" style="color: #3b3f44; font-family: arial,helvetica,sans-serif; font-size: 18px; line-height: 1.5; text-align: center;">
<div>
<p style="margin: 0; font-size: 14px;">This email was sent to {{email}}</p>
</div>
<td valign="top" class="">
<table width="100%" cellspacing="0" cellpadding="0" border="0" role="presentation">
<tr>
<td class="r21-c" style="display: inline-block;">
<table cellspacing="0" cellpadding="0" border="0" role="presentation" width="570" class="r7-o" style="table-layout: fixed; width: 570px;">
<!-- -->
<tr class="nl2go-responsive-hide">
<td height="15" width="209" style="font-size: 15px; line-height: 15px;">­ </td>
<td height="15" style="font-size: 15px; line-height: 15px;">­</td>
<td height="15" width="209" style="font-size: 15px; line-height: 15px;">­ </td>
</tr>
<tr>
<td class="nl2go-responsive-hide" width="209" style="font-size: 0px; line-height: 1px;">­ </td>
<td class="r22-i">
<table width="100%" cellspacing="0" cellpadding="0" border="0" role="presentation">
<tr>
<th width="40" valign="" class="r23-c mobshow resp-table" style="font-weight: normal;">
<table cellspacing="0" cellpadding="0" border="0" role="presentation" width="100%" class="r24-o" style="table-layout: fixed; width: 100%;">
<!-- -->
<tr class="nl2go-responsive-hide">
<td height="5" style="font-size: 5px; line-height: 5px;">­</td>
<td height="5" width="8" style="font-size: 5px; line-height: 5px;">­ </td>
</tr>
<tr>
<td class="r25-i" style="font-size: 0px; line-height: 0px;"> <a href="https://github.com/makeplane/plane" target="_blank" style="color: #0092ff; text-decoration: underline;"> <img src="https://sendinblue-templates.s3.eu-west-3.amazonaws.com/icons/rounded_colored/github_32px.png" width="32" border="0" class="" style="display: block; width: 100%;"></a> </td>
<td class="nl2go-responsive-hide" width="8" style="font-size: 0px; line-height: 1px;">­ </td>
</tr>
<tr class="nl2go-responsive-hide">
<td height="5" style="font-size: 5px; line-height: 5px;">­</td>
<td height="5" width="8" style="font-size: 5px; line-height: 5px;">­ </td>
</tr>
</table>
</th>
<th width="40" valign="" class="r23-c mobshow resp-table" style="font-weight: normal;">
<table cellspacing="0" cellpadding="0" border="0" role="presentation" width="100%" class="r24-o" style="table-layout: fixed; width: 100%;">
<!-- -->
<tr class="nl2go-responsive-hide">
<td height="5" style="font-size: 5px; line-height: 5px;">­</td>
<td height="5" width="8" style="font-size: 5px; line-height: 5px;">­ </td>
</tr>
<tr>
<td class="r25-i" style="font-size: 0px; line-height: 0px;"> <a href="https://discord.gg/A92xrEGCge" target="_blank" style="color: #0092ff; text-decoration: underline;"> <img src="https://sendinblue-templates.s3.eu-west-3.amazonaws.com/icons/rounded_colored/discord_32px.png" width="32" border="0" class="" style="display: block; width: 100%;"></a> </td>
<td class="nl2go-responsive-hide" width="8" style="font-size: 0px; line-height: 1px;">­ </td>
</tr>
<tr class="nl2go-responsive-hide">
<td height="5" style="font-size: 5px; line-height: 5px;">­</td>
<td height="5" width="8" style="font-size: 5px; line-height: 5px;">­ </td>
</tr>
</table>
</th>
<th width="40" valign="" class="r23-c mobshow resp-table" style="font-weight: normal;">
<table cellspacing="0" cellpadding="0" border="0" role="presentation" width="100%" class="r24-o" style="table-layout: fixed; width: 100%;">
<!-- -->
<tr class="nl2go-responsive-hide">
<td height="5" style="font-size: 5px; line-height: 5px;">­</td>
<td height="5" width="8" style="font-size: 5px; line-height: 5px;">­ </td>
</tr>
<tr>
<td class="r25-i" style="font-size: 0px; line-height: 0px;"> <a href="https://twitter.com/planepowers" target="_blank" style="color: #0092ff; text-decoration: underline;"> <img src="https://sendinblue-templates.s3.eu-west-3.amazonaws.com/icons/rounded_colored/twitter_32px.png" width="32" border="0" class="" style="display: block; width: 100%;"></a> </td>
<td class="nl2go-responsive-hide" width="8" style="font-size: 0px; line-height: 1px;">­ </td>
</tr>
<tr class="nl2go-responsive-hide">
<td height="5" style="font-size: 5px; line-height: 5px;">­</td>
<td height="5" width="8" style="font-size: 5px; line-height: 5px;">­ </td>
</tr>
</table>
</th>
<th width="32" valign="" class="r23-c mobshow resp-table" style="font-weight: normal;">
<table cellspacing="0" cellpadding="0" border="0" role="presentation" width="100%" class="r26-o" style="table-layout: fixed; width: 100%;">
<!-- -->
<tr class="nl2go-responsive-hide">
<td height="5" style="font-size: 5px; line-height: 5px;">­</td>
</tr>
<tr>
<td class="r25-i" style="font-size: 0px; line-height: 0px;"> <a href="https://www.linkedin.com/company/planepowers/" target="_blank" style="color: #0092ff; text-decoration: underline;"> <img src="https://sendinblue-templates.s3.eu-west-3.amazonaws.com/icons/rounded_colored/linkedin_32px.png" width="32" border="0" class="" style="display: block; width: 100%;"></a> </td>
</tr>
<tr class="nl2go-responsive-hide">
<td height="5" style="font-size: 5px; line-height: 5px;">­</td>
</tr>
</table>
</th>
</tr>
</table>
</td>
<td class="nl2go-responsive-hide" width="209" style="font-size: 0px; line-height: 1px;">­ </td>
</tr>
<tr class="nl2go-responsive-hide">
<td height="15" width="209" style="font-size: 15px; line-height: 15px;">­ </td>
<td height="15" style="font-size: 15px; line-height: 15px;">­</td>
<td height="15" width="209" style="font-size: 15px; line-height: 15px;">­ </td>
</tr>
</table>
</td>
</tr>
</table>
</td>
</tr>
</table>
</td>
</tr>
<tr>
<td class="r12-c" align="left">
<table cellspacing="0" cellpadding="0" border="0" role="presentation" width="100%" class="r13-o" style="table-layout: fixed; width: 100%;">
<tr>
<td align="center" valign="top" class="r22-i nl2go-default-textstyle" style="color: #3b3f44; font-family: arial,helvetica,sans-serif; font-size: 18px; line-height: 1.5; text-align: center;">
<div>
<p style="margin: 0; font-size: 14px;">You've received this email because you've subscribed to our newsletter.</p>
</div>
</td>
</tr>
</table>
</td>
</tr>
<tr>
<td class="r12-c" align="left">
<table cellspacing="0" cellpadding="0" border="0" role="presentation" width="100%" class="r13-o" style="table-layout: fixed; width: 100%;">
<td class="r10-c" align="left">
<table cellspacing="0" cellpadding="0" border="0" role="presentation" width="100%" class="r11-o" style="table-layout: fixed; width: 100%;">
<tr class="nl2go-responsive-hide">
<td height="15" style="font-size: 15px; line-height: 15px;">­</td>
</tr>
<tr>
<td align="center" valign="top" class="r23-i nl2go-default-textstyle" style="color: #3b3f44; font-family: arial,helvetica,sans-serif; font-size: 18px; line-height: 1.5; text-align: center;">
<td align="center" valign="top" class="r27-i nl2go-default-textstyle" style="font-family: arial,helvetica,sans-serif; color: #3b3f44; font-size: 18px; line-height: 1.5; text-align: center;">
<div>
<p style="margin: 0; font-size: 14px;"><span condition="{{ update_profile }}" style="display: none;"><a href="{{ update_profile }}" style="color: #0092ff; text-decoration: underline;">Update your preference</a> | </span> <a href="{{ unsubscribe }}" style="color: #0092ff; text-decoration: underline;">Unsubscribe</a></p>
<p style="margin: 0; font-size: 14px;"><a href="{{ mirror }}" style="color: #0092ff; text-decoration: underline;">View in browser</a> | <a href="{{ unsubscribe }}" style="color: #0092ff; text-decoration: underline;">Unsubscribe</a></p>
</div>
</td>
</tr>

View File

@ -1,13 +1,349 @@
<!DOCTYPE html>
<html>
<p>
Dear {{email}},<br /><br />
Welcome!<br />
{{first_name}} has invited you to join {{project_name}}!<br /><br />
Invitation Link: {{invitation_url}}
<br /><br />
</p>
<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Strict//EN" "http://www.w3.org/TR/xhtml1/DTD/xhtml1-strict.dtd">
<html xmlns="http://www.w3.org/1999/xhtml" xmlns:v="urn:schemas-microsoft-com:vml" xmlns:o="urn:schemas-microsoft-com:office:office">
<head>
<meta http-equiv="Content-Type" content="text/html; charset=utf-8">
<meta http-equiv="X-UA-Compatible" content="IE=edge">
<meta name="format-detection" content="telephone=no">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>{{ Inviter }} invited you to join {{ Workspace-Name }} on Plane</title>
<style type="text/css" emogrify="no">#outlook a { padding:0; } .ExternalClass { width:100%; } .ExternalClass, .ExternalClass p, .ExternalClass span, .ExternalClass font, .ExternalClass td, .ExternalClass div { line-height: 100%; } table td { border-collapse: collapse; mso-line-height-rule: exactly; } .editable.image { font-size: 0 !important; line-height: 0 !important; } .nl2go_preheader { display: none !important; mso-hide:all !important; mso-line-height-rule: exactly; visibility: hidden !important; line-height: 0px !important; font-size: 0px !important; } body { width:100% !important; -webkit-text-size-adjust:100%; -ms-text-size-adjust:100%; margin:0; padding:0; } img { outline:none; text-decoration:none; -ms-interpolation-mode: bicubic; } a img { border:none; } table { border-collapse:collapse; mso-table-lspace:0pt; mso-table-rspace:0pt; } th { font-weight: normal; text-align: left; } *[class="gmail-fix"] { display: none !important; } </style>
<style type="text/css" emogrify="no"> @media (max-width: 600px) { .gmx-killpill { content: ' \03D1';} } </style>
<style type="text/css" emogrify="no">@media (max-width: 600px) { .gmx-killpill { content: ' \03D1';} .r0-c { box-sizing: border-box !important; text-align: center !important; valign: top !important; width: 320px !important } .r1-o { border-style: solid !important; margin: 0 auto 0 auto !important; width: 320px !important } .r2-i { background-color: #ffffff !important } .r3-c { box-sizing: border-box !important; text-align: center !important; valign: top !important; width: 100% !important } .r4-o { border-style: solid !important; margin: 0 auto 0 auto !important; margin-top: 20px !important; width: 100% !important } .r5-i { background-color: #f8f9fa !important; padding-bottom: 20px !important; padding-left: 10px !important; padding-right: 10px !important; padding-top: 20px !important } .r6-c { box-sizing: border-box !important; display: block !important; valign: top !important; width: 100% !important } .r7-o { border-style: solid !important; width: 100% !important } .r8-i { padding-left: 0px !important; padding-right: 0px !important } .r9-o { border-style: solid !important; margin: 0 auto 0 auto !important; width: 100% !important } .r10-i { padding-bottom: 35px !important; padding-top: 15px !important } .r11-c { box-sizing: border-box !important; text-align: left !important; valign: top !important; width: 100% !important } .r12-o { border-style: solid !important; margin: 0 auto 0 0 !important; width: 100% !important } .r13-i { padding-left: 20px !important; padding-right: 20px !important; padding-top: 0px !important; text-align: center !important } .r14-o { border-style: solid !important; margin: 0 auto 0 auto !important; margin-bottom: 20px !important; margin-top: 20px !important; width: 100% !important } .r15-i { text-align: center !important } .r16-r { background-color: #ffffff !important; border-color: #3f76ff !important; border-radius: 4px !important; border-width: 1px !important; box-sizing: border-box; height: initial !important; padding-bottom: 7px !important; padding-left: 20px !important; padding-right: 20px !important; padding-top: 7px !important; text-align: center !important; width: 100% !important } .r17-i { padding-bottom: 15px !important; padding-left: 20px !important; padding-right: 20px !important; padding-top: 15px !important; text-align: left !important } .r18-i { background-color: #eff2f7 !important; padding-bottom: 20px !important; padding-left: 15px !important; padding-right: 15px !important; padding-top: 20px !important } .r19-i { padding-bottom: 15px !important; padding-top: 15px !important } .r20-i { color: #3b3f44 !important; padding-bottom: 0px !important; padding-top: 0px !important; text-align: center !important } .r21-c { box-sizing: border-box !important; text-align: center !important; width: 100% !important } .r22-c { box-sizing: border-box !important; width: 100% !important } .r23-i { font-size: 0px !important; padding-bottom: 15px !important; padding-left: 65px !important; padding-right: 65px !important; padding-top: 15px !important } .r24-c { box-sizing: border-box !important; width: 32px !important } .r25-o { border-style: solid !important; margin-right: 8px !important; width: 32px !important } .r26-i { padding-bottom: 5px !important; padding-top: 5px !important } .r27-o { border-style: solid !important; margin-right: 0px !important; width: 32px !important } .r28-i { color: #3b3f44 !important; padding-bottom: 15px !important; padding-top: 15px !important; text-align: center !important } .r29-i { padding-bottom: 15px !important; padding-left: 0px !important; padding-right: 0px !important; padding-top: 0px !important } .r30-c { box-sizing: border-box !important; text-align: center !important; valign: top !important; width: 129px !important } .r31-o { border-style: solid !important; margin: 0 auto 0 auto !important; width: 129px !important } body { -webkit-text-size-adjust: none } .nl2go-responsive-hide { display: none } .nl2go-body-table { min-width: unset !important } .mobshow { height: auto !important; overflow: visible !important; max-height: unset !important; visibility: visible !important; border: none !important } .resp-table { display: inline-table !important } .magic-resp { display: table-cell !important } } </style>
<!--[if !mso]><!-->
<style type="text/css" emogrify="no">@import url("https://fonts.googleapis.com/css2?family=Bitter&family=Roboto"); </style>
<!--<![endif]-->
<style type="text/css">p, h1, h2, h3, h4, ol, ul { margin: 0; } a, a:link { color: #0092ff; text-decoration: underline } .nl2go-default-textstyle { color: #3b3f44; font-family: arial,helvetica,sans-serif; font-size: 16px; line-height: 1.5 } .default-button { border-radius: 4px; color: #ffffff; font-family: arial,helvetica,sans-serif; font-size: 16px; font-style: normal; font-weight: bold; line-height: 1.15; text-decoration: none; width: 50% } .default-heading1 { color: #1F2D3D; font-family: arial,helvetica,sans-serif; font-size: 36px } .default-heading2 { color: #1F2D3D; font-family: arial,helvetica,sans-serif; font-size: 32px } .default-heading3 { color: #1F2D3D; font-family: arial,helvetica,sans-serif; font-size: 24px } .default-heading4 { color: #1F2D3D; font-family: arial,helvetica,sans-serif; font-size: 18px } a[x-apple-data-detectors] { color: inherit !important; text-decoration: inherit !important; font-size: inherit !important; font-family: inherit !important; font-weight: inherit !important; line-height: inherit !important; } .no-show-for-you { border: none; display: none; float: none; font-size: 0; height: 0; line-height: 0; max-height: 0; mso-hide: all; overflow: hidden; table-layout: fixed; visibility: hidden; width: 0; } </style>
<!--[if mso]>
<xml>
<o:OfficeDocumentSettings>
<o:AllowPNG/>
<o:PixelsPerInch>96</o:PixelsPerInch>
</o:OfficeDocumentSettings>
</xml>
<![endif]-->
<style type="text/css">a:link{color: #0092ff; text-decoration: underline;}</style>
</head>
<body bgcolor="#ffffff" text="#3b3f44" link="#0092ff" yahoo="fix" style="background-color: #ffffff; padding-bottom: 0px; padding-top: 0px;">
<table cellspacing="0" cellpadding="0" border="0" role="presentation" class="nl2go-body-table" width="100%" style="background-color: #ffffff; width: 100%;">
<tr>
<td align="center" class="r0-c">
<table cellspacing="0" cellpadding="0" border="0" role="presentation" width="600" class="r1-o" style="table-layout: fixed; width: 600px;">
<tr>
<td valign="top" class="r2-i" style="background-color: #ffffff;">
<table width="100%" cellspacing="0" cellpadding="0" border="0" role="presentation">
<tr>
<td class="r3-c" align="center">
<table cellspacing="0" cellpadding="0" border="0" role="presentation" width="100%" class="r4-o" style="table-layout: fixed; width: 100%;">
<!-- -->
<tr class="nl2go-responsive-hide">
<td height="20" style="font-size: 20px; line-height: 20px;">­</td>
</tr>
<tr class="nl2go-responsive-hide">
<td height="20" style="font-size: 20px; line-height: 20px; background-color: #f8f9fa;">­</td>
</tr>
<tr>
<td class="r5-i" style="background-color: #f8f9fa;">
<table width="100%" cellspacing="0" cellpadding="0" border="0" role="presentation">
<tr>
<th width="100%" valign="top" class="r6-c" style="font-weight: normal;">
<table cellspacing="0" cellpadding="0" border="0" role="presentation" width="100%" class="r7-o" style="table-layout: fixed; width: 100%;">
<!-- -->
<tr>
<td valign="top" class="r8-i">
<table width="100%" cellspacing="0" cellpadding="0" border="0" role="presentation">
<tr>
<td class="r3-c" align="center">
<table cellspacing="0" cellpadding="0" border="0" role="presentation" width="120" class="r9-o" style="table-layout: fixed; width: 120px;">
<tr class="nl2go-responsive-hide">
<td height="15" style="font-size: 15px; line-height: 15px;">­</td>
</tr>
<tr>
<td class="r10-i" style="font-size: 0px; line-height: 0px;"> <img src="https://ik.imagekit.io/w2okwbtu2/Plane_Logo_pIhtbyIoa.png?ik-sdk-version=javascript-1.4.3&updatedAt=1670873447444" width="120" border="0" class="" style="display: block; width: 100%;"></td>
</tr>
<tr class="nl2go-responsive-hide">
<td height="35" style="font-size: 35px; line-height: 35px;">­</td>
</tr>
</table>
</td>
</tr>
<tr>
<td class="r11-c" align="left">
<table cellspacing="0" cellpadding="0" border="0" role="presentation" width="100%" class="r12-o" style="table-layout: fixed; width: 100%;">
<tr>
<td class="nl2go-responsive-hide" width="20" style="font-size: 0px; line-height: 1px;">­ </td>
<td align="center" valign="top" class="r13-i nl2go-default-textstyle" style="color: #3b3f44; font-family: arial,helvetica,sans-serif; font-size: 16px; line-height: 1.5; text-align: center;">
<div>
<p style="margin: 0;"><span style="font-size: 26px;"><strong>{{first_name}}</strong> has invited you to join the </span></p>
<p style="margin: 0;"><span style="font-size: 26px;"><strong>{{project_name}}</strong> project on Plane</span></p>
</div>
</td>
<td class="nl2go-responsive-hide" width="20" style="font-size: 0px; line-height: 1px;">­ </td>
</tr>
</table>
</td>
</tr>
<tr>
<td class="r3-c" align="center">
<table cellspacing="0" cellpadding="0" border="0" role="presentation" width="300" class="r14-o" style="table-layout: fixed; width: 300px;">
<tr class="nl2go-responsive-hide">
<td height="20" style="font-size: 20px; line-height: 20px;">­</td>
</tr>
<tr>
<td height="18" align="center" valign="top" class="r15-i nl2go-default-textstyle" style="color: #3b3f44; font-family: arial,helvetica,sans-serif; font-size: 16px; line-height: 1.5;">
<!--[if mso]>
<v:roundrect xmlns:v="urn:schemas-microsoft-com:vml" xmlns:w="urn:schemas-microsoft-com:office:word" href="https://app.plane.so/" style="v-text-anchor:middle; height: 33px; width: 301px;" arcsize="12%" fillcolor="#ffffff" strokecolor="#3f76ff" strokeweight="1px" data-btn="1">
<w:anchorlock/>
<div style="display:none;">
<center class="default-button">
<p><span style="color:#3F76FF;">Accept the invite</span></p>
</center>
</div>
</v:roundrect>
<![endif]--> <!--[if !mso]><!-- -->
<a href={{invitation_url}} class="r16-r default-button" target="_blank" data-btn="1" style="font-style: normal; font-weight: bold; line-height: 1.15; text-decoration: none; border-style: solid; word-wrap: break-word; display: inline-block; -webkit-text-size-adjust: none; mso-hide: all; background-color: #ffffff; border-color: #3f76ff; border-radius: 4px; border-width: 1px; color: #ffffff; font-family: arial,helvetica,sans-serif; font-size: 16px; height: 18px; padding-bottom: 7px; padding-left: 20px; padding-right: 20px; padding-top: 7px; width: 258px;">
<p style="margin: 0;"><span style="color: #3F76FF;">Accept the invite</span></p>
</a>
<!--<![endif]-->
</td>
</tr>
<tr class="nl2go-responsive-hide">
<td height="20" style="font-size: 20px; line-height: 20px;">­</td>
</tr>
</table>
</td>
</tr>
<tr>
<td class="r11-c" align="left">
<table cellspacing="0" cellpadding="0" border="0" role="presentation" width="100%" class="r12-o" style="table-layout: fixed; width: 100%;">
<tr class="nl2go-responsive-hide">
<td height="15" width="20" style="font-size: 15px; line-height: 15px;">­ </td>
<td height="15" style="font-size: 15px; line-height: 15px;">­</td>
<td height="15" width="20" style="font-size: 15px; line-height: 15px;">­ </td>
</tr>
<tr>
<td class="nl2go-responsive-hide" width="20" style="font-size: 0px; line-height: 1px;">­ </td>
<td align="left" valign="top" class="r17-i nl2go-default-textstyle" style="color: #3b3f44; font-family: arial,helvetica,sans-serif; font-size: 16px; line-height: 1.5; text-align: left;">
<div>
<p style="margin: 0;"><span style="font-size: 12px;">Note: 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 </span><a href="https://discord.gg/A92xrEGCge" target="_blank" style="color: #0092ff; text-decoration: underline;"><span style="font-size: 12px;">Discord</span></a><span style="font-size: 12px;"> or </span><a href="https://github.com/makeplane/plane" target="_blank" style="color: #0092ff; text-decoration: underline;"><span style="font-size: 12px;">GitHub</span></a><span style="font-size: 12px;">, and we will use your feedback to improve on our upcoming releases.</span></p>
</div>
</td>
<td class="nl2go-responsive-hide" width="20" style="font-size: 0px; line-height: 1px;">­ </td>
</tr>
<tr class="nl2go-responsive-hide">
<td height="15" width="20" style="font-size: 15px; line-height: 15px;">­ </td>
<td height="15" style="font-size: 15px; line-height: 15px;">­</td>
<td height="15" width="20" style="font-size: 15px; line-height: 15px;">­ </td>
</tr>
</table>
</td>
</tr>
</table>
</td>
</tr>
</table>
</th>
</tr>
</table>
</td>
</tr>
<tr class="nl2go-responsive-hide">
<td height="20" style="font-size: 20px; line-height: 20px; background-color: #f8f9fa;">­</td>
</tr>
</table>
</td>
</tr>
<tr>
<td class="r3-c" align="center">
<table cellspacing="0" cellpadding="0" border="0" role="presentation" width="100%" class="r9-o" style="table-layout: fixed; width: 100%;">
<!-- -->
<tr class="nl2go-responsive-hide">
<td height="20" style="font-size: 20px; line-height: 20px; background-color: #eff2f7;">­</td>
</tr>
<tr>
<td class="r18-i" style="background-color: #eff2f7;">
<table width="100%" cellspacing="0" cellpadding="0" border="0" role="presentation">
<tr>
<th width="100%" valign="top" class="r6-c" style="font-weight: normal;">
<table cellspacing="0" cellpadding="0" border="0" role="presentation" width="100%" class="r7-o" style="table-layout: fixed; width: 100%;">
<!-- -->
<tr>
<td class="nl2go-responsive-hide" width="15" style="font-size: 0px; line-height: 1px;">­ </td>
<td valign="top" class="r8-i">
<table width="100%" cellspacing="0" cellpadding="0" border="0" role="presentation">
<tr>
<td class="r3-c" align="center">
<table cellspacing="0" cellpadding="0" border="0" role="presentation" width="57" class="r9-o" style="table-layout: fixed; width: 57px;">
<tr class="nl2go-responsive-hide">
<td height="15" style="font-size: 15px; line-height: 15px;">­</td>
</tr>
<tr>
<td class="r19-i" style="font-size: 0px; line-height: 0px;"> <img src="https://ik.imagekit.io/w2okwbtu2/115727700_n9t8rrnwT.png?ik-sdk-version=javascript-1.4.3&updatedAt=1670872429989" width="57" border="0" class="" style="display: block; width: 100%;"></td>
</tr>
<tr class="nl2go-responsive-hide">
<td height="15" style="font-size: 15px; line-height: 15px;">­</td>
</tr>
</table>
</td>
</tr>
<tr>
<td class="r11-c" align="left">
<table cellspacing="0" cellpadding="0" border="0" role="presentation" width="100%" class="r12-o" style="table-layout: fixed; width: 100%;">
<tr>
<td align="center" valign="top" class="r20-i nl2go-default-textstyle" style="font-family: arial,helvetica,sans-serif; color: #3b3f44; font-size: 18px; line-height: 1.5; text-align: center;">
<div>
<p style="margin: 0; font-size: 14px;">Proudly made on <strong>Planet Earth 🌍</strong>.</p>
</div>
</td>
</tr>
</table>
</td>
</tr>
<tr>
<td class="r21-c" align="center">
<table cellspacing="0" cellpadding="0" border="0" role="presentation" width="570" class="r9-o" style="table-layout: fixed; width: 570px;">
<!-- -->
<tr>
<td valign="top" class="">
<table width="100%" cellspacing="0" cellpadding="0" border="0" role="presentation">
<tr>
<td class="r22-c" style="display: inline-block;">
<table cellspacing="0" cellpadding="0" border="0" role="presentation" width="570" class="r7-o" style="table-layout: fixed; width: 570px;">
<!-- -->
<tr class="nl2go-responsive-hide">
<td height="15" width="209" style="font-size: 15px; line-height: 15px;">­ </td>
<td height="15" style="font-size: 15px; line-height: 15px;">­</td>
<td height="15" width="209" style="font-size: 15px; line-height: 15px;">­ </td>
</tr>
<tr>
<td class="nl2go-responsive-hide" width="209" style="font-size: 0px; line-height: 1px;">­ </td>
<td class="r23-i">
<table width="100%" cellspacing="0" cellpadding="0" border="0" role="presentation">
<tr>
<th width="40" valign="" class="r24-c mobshow resp-table" style="font-weight: normal;">
<table cellspacing="0" cellpadding="0" border="0" role="presentation" width="100%" class="r25-o" style="table-layout: fixed; width: 100%;">
<!-- -->
<tr class="nl2go-responsive-hide">
<td height="5" style="font-size: 5px; line-height: 5px;">­</td>
<td height="5" width="8" style="font-size: 5px; line-height: 5px;">­ </td>
</tr>
<tr>
<td class="r26-i" style="font-size: 0px; line-height: 0px;"> <a href="https://github.com/makeplane/plane" target="_blank" style="color: #0092ff; text-decoration: underline;"> <img src="https://sendinblue-templates.s3.eu-west-3.amazonaws.com/icons/rounded_colored/github_32px.png" width="32" border="0" class="" style="display: block; width: 100%;"></a> </td>
<td class="nl2go-responsive-hide" width="8" style="font-size: 0px; line-height: 1px;">­ </td>
</tr>
<tr class="nl2go-responsive-hide">
<td height="5" style="font-size: 5px; line-height: 5px;">­</td>
<td height="5" width="8" style="font-size: 5px; line-height: 5px;">­ </td>
</tr>
</table>
</th>
<th width="40" valign="" class="r24-c mobshow resp-table" style="font-weight: normal;">
<table cellspacing="0" cellpadding="0" border="0" role="presentation" width="100%" class="r25-o" style="table-layout: fixed; width: 100%;">
<!-- -->
<tr class="nl2go-responsive-hide">
<td height="5" style="font-size: 5px; line-height: 5px;">­</td>
<td height="5" width="8" style="font-size: 5px; line-height: 5px;">­ </td>
</tr>
<tr>
<td class="r26-i" style="font-size: 0px; line-height: 0px;"> <a href="https://discord.gg/A92xrEGCge" target="_blank" style="color: #0092ff; text-decoration: underline;"> <img src="https://sendinblue-templates.s3.eu-west-3.amazonaws.com/icons/rounded_colored/discord_32px.png" width="32" border="0" class="" style="display: block; width: 100%;"></a> </td>
<td class="nl2go-responsive-hide" width="8" style="font-size: 0px; line-height: 1px;">­ </td>
</tr>
<tr class="nl2go-responsive-hide">
<td height="5" style="font-size: 5px; line-height: 5px;">­</td>
<td height="5" width="8" style="font-size: 5px; line-height: 5px;">­ </td>
</tr>
</table>
</th>
<th width="40" valign="" class="r24-c mobshow resp-table" style="font-weight: normal;">
<table cellspacing="0" cellpadding="0" border="0" role="presentation" width="100%" class="r25-o" style="table-layout: fixed; width: 100%;">
<!-- -->
<tr class="nl2go-responsive-hide">
<td height="5" style="font-size: 5px; line-height: 5px;">­</td>
<td height="5" width="8" style="font-size: 5px; line-height: 5px;">­ </td>
</tr>
<tr>
<td class="r26-i" style="font-size: 0px; line-height: 0px;"> <a href="https://twitter.com/planepowers" target="_blank" style="color: #0092ff; text-decoration: underline;"> <img src="https://sendinblue-templates.s3.eu-west-3.amazonaws.com/icons/rounded_colored/twitter_32px.png" width="32" border="0" class="" style="display: block; width: 100%;"></a> </td>
<td class="nl2go-responsive-hide" width="8" style="font-size: 0px; line-height: 1px;">­ </td>
</tr>
<tr class="nl2go-responsive-hide">
<td height="5" style="font-size: 5px; line-height: 5px;">­</td>
<td height="5" width="8" style="font-size: 5px; line-height: 5px;">­ </td>
</tr>
</table>
</th>
<th width="32" valign="" class="r24-c mobshow resp-table" style="font-weight: normal;">
<table cellspacing="0" cellpadding="0" border="0" role="presentation" width="100%" class="r27-o" style="table-layout: fixed; width: 100%;">
<!-- -->
<tr class="nl2go-responsive-hide">
<td height="5" style="font-size: 5px; line-height: 5px;">­</td>
</tr>
<tr>
<td class="r26-i" style="font-size: 0px; line-height: 0px;"> <a href="https://www.linkedin.com/company/planepowers/" target="_blank" style="color: #0092ff; text-decoration: underline;"> <img src="https://sendinblue-templates.s3.eu-west-3.amazonaws.com/icons/rounded_colored/linkedin_32px.png" width="32" border="0" class="" style="display: block; width: 100%;"></a> </td>
</tr>
<tr class="nl2go-responsive-hide">
<td height="5" style="font-size: 5px; line-height: 5px;">­</td>
</tr>
</table>
</th>
</tr>
</table>
</td>
<td class="nl2go-responsive-hide" width="209" style="font-size: 0px; line-height: 1px;">­ </td>
</tr>
<tr class="nl2go-responsive-hide">
<td height="15" width="209" style="font-size: 15px; line-height: 15px;">­ </td>
<td height="15" style="font-size: 15px; line-height: 15px;">­</td>
<td height="15" width="209" style="font-size: 15px; line-height: 15px;">­ </td>
</tr>
</table>
</td>
</tr>
</table>
</td>
</tr>
</table>
</td>
</tr>
<tr>
<td class="r11-c" align="left">
<table cellspacing="0" cellpadding="0" border="0" role="presentation" width="100%" class="r12-o" style="table-layout: fixed; width: 100%;">
<tr class="nl2go-responsive-hide">
<td height="15" style="font-size: 15px; line-height: 15px;">­</td>
</tr>
<tr>
<td align="center" valign="top" class="r28-i nl2go-default-textstyle" style="font-family: arial,helvetica,sans-serif; color: #3b3f44; font-size: 18px; line-height: 1.5; text-align: center;">
<div>
<p style="margin: 0; font-size: 14px;"><a href="{{ mirror }}" style="color: #0092ff; text-decoration: underline;">View in browser</a> | <a href="{{ unsubscribe }}" style="color: #0092ff; text-decoration: underline;">Unsubscribe</a></p>
</div>
</td>
</tr>
<tr class="nl2go-responsive-hide">
<td height="15" style="font-size: 15px; line-height: 15px;">­</td>
</tr>
</table>
</td>
</tr>
</table>
</td>
<td class="nl2go-responsive-hide" width="15" style="font-size: 0px; line-height: 1px;">­ </td>
</tr>
</table>
</th>
</tr>
</table>
</td>
</tr>
<tr class="nl2go-responsive-hide">
<td height="20" style="font-size: 20px; line-height: 20px; background-color: #eff2f7;">­</td>
</tr>
</table>
</td>
</tr>
</table>
</td>
</tr>
</table>
</td>
</tr>
</table>
</body>
</html>

View File

@ -1,12 +1,349 @@
<!DOCTYPE html>
<html>
<p>
Dear {{email}},<br /><br />
Welcome!<br />
{{first_name}} has invited you to join {{workspace_name}}!<br/><br />
Invitation Link: {{invitation_url}}
<br /><br />
</p>
</html>
<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Strict//EN" "http://www.w3.org/TR/xhtml1/DTD/xhtml1-strict.dtd">
<html xmlns="http://www.w3.org/1999/xhtml" xmlns:v="urn:schemas-microsoft-com:vml" xmlns:o="urn:schemas-microsoft-com:office:office">
<head>
<meta http-equiv="Content-Type" content="text/html; charset=utf-8">
<meta http-equiv="X-UA-Compatible" content="IE=edge">
<meta name="format-detection" content="telephone=no">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>{{first_name}} invited you to join {{workspace_name}} on Plane</title>
<style type="text/css" emogrify="no">#outlook a { padding:0; } .ExternalClass { width:100%; } .ExternalClass, .ExternalClass p, .ExternalClass span, .ExternalClass font, .ExternalClass td, .ExternalClass div { line-height: 100%; } table td { border-collapse: collapse; mso-line-height-rule: exactly; } .editable.image { font-size: 0 !important; line-height: 0 !important; } .nl2go_preheader { display: none !important; mso-hide:all !important; mso-line-height-rule: exactly; visibility: hidden !important; line-height: 0px !important; font-size: 0px !important; } body { width:100% !important; -webkit-text-size-adjust:100%; -ms-text-size-adjust:100%; margin:0; padding:0; } img { outline:none; text-decoration:none; -ms-interpolation-mode: bicubic; } a img { border:none; } table { border-collapse:collapse; mso-table-lspace:0pt; mso-table-rspace:0pt; } th { font-weight: normal; text-align: left; } *[class="gmail-fix"] { display: none !important; } </style>
<style type="text/css" emogrify="no"> @media (max-width: 600px) { .gmx-killpill { content: ' \03D1';} } </style>
<style type="text/css" emogrify="no">@media (max-width: 600px) { .gmx-killpill { content: ' \03D1';} .r0-c { box-sizing: border-box !important; text-align: center !important; valign: top !important; width: 320px !important } .r1-o { border-style: solid !important; margin: 0 auto 0 auto !important; width: 320px !important } .r2-i { background-color: #ffffff !important } .r3-c { box-sizing: border-box !important; text-align: center !important; valign: top !important; width: 100% !important } .r4-o { border-style: solid !important; margin: 0 auto 0 auto !important; margin-top: 20px !important; width: 100% !important } .r5-i { background-color: #f8f9fa !important; padding-bottom: 20px !important; padding-left: 10px !important; padding-right: 10px !important; padding-top: 20px !important } .r6-c { box-sizing: border-box !important; display: block !important; valign: top !important; width: 100% !important } .r7-o { border-style: solid !important; width: 100% !important } .r8-i { padding-left: 0px !important; padding-right: 0px !important } .r9-o { border-style: solid !important; margin: 0 auto 0 auto !important; width: 100% !important } .r10-i { padding-bottom: 35px !important; padding-top: 15px !important } .r11-c { box-sizing: border-box !important; text-align: left !important; valign: top !important; width: 100% !important } .r12-o { border-style: solid !important; margin: 0 auto 0 0 !important; width: 100% !important } .r13-i { padding-left: 20px !important; padding-right: 20px !important; padding-top: 0px !important; text-align: center !important } .r14-o { border-style: solid !important; margin: 0 auto 0 auto !important; margin-bottom: 20px !important; margin-top: 20px !important; width: 100% !important } .r15-i { text-align: center !important } .r16-r { background-color: #ffffff !important; border-color: #3f76ff !important; border-radius: 4px !important; border-width: 1px !important; box-sizing: border-box; height: initial !important; padding-bottom: 7px !important; padding-left: 20px !important; padding-right: 20px !important; padding-top: 7px !important; text-align: center !important; width: 100% !important } .r17-i { padding-bottom: 15px !important; padding-left: 20px !important; padding-right: 20px !important; padding-top: 15px !important; text-align: left !important } .r18-i { background-color: #eff2f7 !important; padding-bottom: 20px !important; padding-left: 15px !important; padding-right: 15px !important; padding-top: 20px !important } .r19-i { padding-bottom: 15px !important; padding-top: 15px !important } .r20-i { color: #3b3f44 !important; padding-bottom: 0px !important; padding-top: 0px !important; text-align: center !important } .r21-c { box-sizing: border-box !important; text-align: center !important; width: 100% !important } .r22-c { box-sizing: border-box !important; width: 100% !important } .r23-i { font-size: 0px !important; padding-bottom: 15px !important; padding-left: 65px !important; padding-right: 65px !important; padding-top: 15px !important } .r24-c { box-sizing: border-box !important; width: 32px !important } .r25-o { border-style: solid !important; margin-right: 8px !important; width: 32px !important } .r26-i { padding-bottom: 5px !important; padding-top: 5px !important } .r27-o { border-style: solid !important; margin-right: 0px !important; width: 32px !important } .r28-i { color: #3b3f44 !important; padding-bottom: 15px !important; padding-top: 15px !important; text-align: center !important } .r29-i { padding-bottom: 15px !important; padding-left: 0px !important; padding-right: 0px !important; padding-top: 0px !important } .r30-c { box-sizing: border-box !important; text-align: center !important; valign: top !important; width: 129px !important } .r31-o { border-style: solid !important; margin: 0 auto 0 auto !important; width: 129px !important } body { -webkit-text-size-adjust: none } .nl2go-responsive-hide { display: none } .nl2go-body-table { min-width: unset !important } .mobshow { height: auto !important; overflow: visible !important; max-height: unset !important; visibility: visible !important; border: none !important } .resp-table { display: inline-table !important } .magic-resp { display: table-cell !important } } </style>
<!--[if !mso]><!-->
<style type="text/css" emogrify="no">@import url("https://fonts.googleapis.com/css2?family=Bitter&family=Roboto"); </style>
<!--<![endif]-->
<style type="text/css">p, h1, h2, h3, h4, ol, ul { margin: 0; } a, a:link { color: #0092ff; text-decoration: underline } .nl2go-default-textstyle { color: #3b3f44; font-family: arial,helvetica,sans-serif; font-size: 16px; line-height: 1.5 } .default-button { border-radius: 4px; color: #ffffff; font-family: arial,helvetica,sans-serif; font-size: 16px; font-style: normal; font-weight: bold; line-height: 1.15; text-decoration: none; width: 50% } .default-heading1 { color: #1F2D3D; font-family: arial,helvetica,sans-serif; font-size: 36px } .default-heading2 { color: #1F2D3D; font-family: arial,helvetica,sans-serif; font-size: 32px } .default-heading3 { color: #1F2D3D; font-family: arial,helvetica,sans-serif; font-size: 24px } .default-heading4 { color: #1F2D3D; font-family: arial,helvetica,sans-serif; font-size: 18px } a[x-apple-data-detectors] { color: inherit !important; text-decoration: inherit !important; font-size: inherit !important; font-family: inherit !important; font-weight: inherit !important; line-height: inherit !important; } .no-show-for-you { border: none; display: none; float: none; font-size: 0; height: 0; line-height: 0; max-height: 0; mso-hide: all; overflow: hidden; table-layout: fixed; visibility: hidden; width: 0; } </style>
<!--[if mso]>
<xml>
<o:OfficeDocumentSettings>
<o:AllowPNG/>
<o:PixelsPerInch>96</o:PixelsPerInch>
</o:OfficeDocumentSettings>
</xml>
<![endif]-->
<style type="text/css">a:link{color: #0092ff; text-decoration: underline;}</style>
</head>
<body bgcolor="#ffffff" text="#3b3f44" link="#0092ff" yahoo="fix" style="background-color: #ffffff; padding-bottom: 0px; padding-top: 0px;">
<table cellspacing="0" cellpadding="0" border="0" role="presentation" class="nl2go-body-table" width="100%" style="background-color: #ffffff; width: 100%;">
<tr>
<td align="center" class="r0-c">
<table cellspacing="0" cellpadding="0" border="0" role="presentation" width="600" class="r1-o" style="table-layout: fixed; width: 600px;">
<tr>
<td valign="top" class="r2-i" style="background-color: #ffffff;">
<table width="100%" cellspacing="0" cellpadding="0" border="0" role="presentation">
<tr>
<td class="r3-c" align="center">
<table cellspacing="0" cellpadding="0" border="0" role="presentation" width="100%" class="r4-o" style="table-layout: fixed; width: 100%;">
<!-- -->
<tr class="nl2go-responsive-hide">
<td height="20" style="font-size: 20px; line-height: 20px;">­</td>
</tr>
<tr class="nl2go-responsive-hide">
<td height="20" style="font-size: 20px; line-height: 20px; background-color: #f8f9fa;">­</td>
</tr>
<tr>
<td class="r5-i" style="background-color: #f8f9fa;">
<table width="100%" cellspacing="0" cellpadding="0" border="0" role="presentation">
<tr>
<th width="100%" valign="top" class="r6-c" style="font-weight: normal;">
<table cellspacing="0" cellpadding="0" border="0" role="presentation" width="100%" class="r7-o" style="table-layout: fixed; width: 100%;">
<!-- -->
<tr>
<td valign="top" class="r8-i">
<table width="100%" cellspacing="0" cellpadding="0" border="0" role="presentation">
<tr>
<td class="r3-c" align="center">
<table cellspacing="0" cellpadding="0" border="0" role="presentation" width="120" class="r9-o" style="table-layout: fixed; width: 120px;">
<tr class="nl2go-responsive-hide">
<td height="15" style="font-size: 15px; line-height: 15px;">­</td>
</tr>
<tr>
<td class="r10-i" style="font-size: 0px; line-height: 0px;"> <img src="https://ik.imagekit.io/w2okwbtu2/Plane_Logo_pIhtbyIoa.png?ik-sdk-version=javascript-1.4.3&updatedAt=1670873447444" width="120" border="0" class="" style="display: block; width: 100%;"></td>
</tr>
<tr class="nl2go-responsive-hide">
<td height="35" style="font-size: 35px; line-height: 35px;">­</td>
</tr>
</table>
</td>
</tr>
<tr>
<td class="r11-c" align="left">
<table cellspacing="0" cellpadding="0" border="0" role="presentation" width="100%" class="r12-o" style="table-layout: fixed; width: 100%;">
<tr>
<td class="nl2go-responsive-hide" width="20" style="font-size: 0px; line-height: 1px;">­ </td>
<td align="center" valign="top" class="r13-i nl2go-default-textstyle" style="color: #3b3f44; font-family: arial,helvetica,sans-serif; font-size: 16px; line-height: 1.5; text-align: center;">
<div>
<p style="margin: 0;"><span style="font-size: 26px;"><strong>{{first_name}}</strong> has invited you to join the </span></p>
<p style="margin: 0;"><span style="font-size: 26px;"><strong>{{workspace_name}}</strong> workspace on Plane</span></p>
</div>
</td>
<td class="nl2go-responsive-hide" width="20" style="font-size: 0px; line-height: 1px;">­ </td>
</tr>
</table>
</td>
</tr>
<tr>
<td class="r3-c" align="center">
<table cellspacing="0" cellpadding="0" border="0" role="presentation" width="300" class="r14-o" style="table-layout: fixed; width: 300px;">
<tr class="nl2go-responsive-hide">
<td height="20" style="font-size: 20px; line-height: 20px;">­</td>
</tr>
<tr>
<td height="18" align="center" valign="top" class="r15-i nl2go-default-textstyle" style="color: #3b3f44; font-family: arial,helvetica,sans-serif; font-size: 16px; line-height: 1.5;">
<!--[if mso]>
<v:roundrect xmlns:v="urn:schemas-microsoft-com:vml" xmlns:w="urn:schemas-microsoft-com:office:word" href="https://app.plane.so/" style="v-text-anchor:middle; height: 33px; width: 301px;" arcsize="12%" fillcolor="#ffffff" strokecolor="#3f76ff" strokeweight="1px" data-btn="1">
<w:anchorlock/>
<div style="display:none;">
<center class="default-button">
<p><span style="color:#3F76FF;">Accept the invite</span></p>
</center>
</div>
</v:roundrect>
<![endif]--> <!--[if !mso]><!-- -->
<a href={{invitation_url}} class="r16-r default-button" target="_blank" data-btn="1" style="font-style: normal; font-weight: bold; line-height: 1.15; text-decoration: none; border-style: solid; word-wrap: break-word; display: inline-block; -webkit-text-size-adjust: none; mso-hide: all; background-color: #ffffff; border-color: #3f76ff; border-radius: 4px; border-width: 1px; color: #ffffff; font-family: arial,helvetica,sans-serif; font-size: 16px; height: 18px; padding-bottom: 7px; padding-left: 20px; padding-right: 20px; padding-top: 7px; width: 258px;">
<p style="margin: 0;"><span style="color: #3F76FF;">Accept the invite</span></p>
</a>
<!--<![endif]-->
</td>
</tr>
<tr class="nl2go-responsive-hide">
<td height="20" style="font-size: 20px; line-height: 20px;">­</td>
</tr>
</table>
</td>
</tr>
<tr>
<td class="r11-c" align="left">
<table cellspacing="0" cellpadding="0" border="0" role="presentation" width="100%" class="r12-o" style="table-layout: fixed; width: 100%;">
<tr class="nl2go-responsive-hide">
<td height="15" width="20" style="font-size: 15px; line-height: 15px;">­ </td>
<td height="15" style="font-size: 15px; line-height: 15px;">­</td>
<td height="15" width="20" style="font-size: 15px; line-height: 15px;">­ </td>
</tr>
<tr>
<td class="nl2go-responsive-hide" width="20" style="font-size: 0px; line-height: 1px;">­ </td>
<td align="left" valign="top" class="r17-i nl2go-default-textstyle" style="color: #3b3f44; font-family: arial,helvetica,sans-serif; font-size: 16px; line-height: 1.5; text-align: left;">
<div>
<p style="margin: 0;"><span style="font-size: 12px;">Note: 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 </span><a href="https://discord.gg/A92xrEGCge" target="_blank" style="color: #0092ff; text-decoration: underline;"><span style="font-size: 12px;">Discord</span></a><span style="font-size: 12px;"> or </span><a href="https://github.com/makeplane/plane" target="_blank" style="color: #0092ff; text-decoration: underline;"><span style="font-size: 12px;">GitHub</span></a><span style="font-size: 12px;">, and we will use your feedback to improve on our upcoming releases.</span></p>
</div>
</td>
<td class="nl2go-responsive-hide" width="20" style="font-size: 0px; line-height: 1px;">­ </td>
</tr>
<tr class="nl2go-responsive-hide">
<td height="15" width="20" style="font-size: 15px; line-height: 15px;">­ </td>
<td height="15" style="font-size: 15px; line-height: 15px;">­</td>
<td height="15" width="20" style="font-size: 15px; line-height: 15px;">­ </td>
</tr>
</table>
</td>
</tr>
</table>
</td>
</tr>
</table>
</th>
</tr>
</table>
</td>
</tr>
<tr class="nl2go-responsive-hide">
<td height="20" style="font-size: 20px; line-height: 20px; background-color: #f8f9fa;">­</td>
</tr>
</table>
</td>
</tr>
<tr>
<td class="r3-c" align="center">
<table cellspacing="0" cellpadding="0" border="0" role="presentation" width="100%" class="r9-o" style="table-layout: fixed; width: 100%;">
<!-- -->
<tr class="nl2go-responsive-hide">
<td height="20" style="font-size: 20px; line-height: 20px; background-color: #eff2f7;">­</td>
</tr>
<tr>
<td class="r18-i" style="background-color: #eff2f7;">
<table width="100%" cellspacing="0" cellpadding="0" border="0" role="presentation">
<tr>
<th width="100%" valign="top" class="r6-c" style="font-weight: normal;">
<table cellspacing="0" cellpadding="0" border="0" role="presentation" width="100%" class="r7-o" style="table-layout: fixed; width: 100%;">
<!-- -->
<tr>
<td class="nl2go-responsive-hide" width="15" style="font-size: 0px; line-height: 1px;">­ </td>
<td valign="top" class="r8-i">
<table width="100%" cellspacing="0" cellpadding="0" border="0" role="presentation">
<tr>
<td class="r3-c" align="center">
<table cellspacing="0" cellpadding="0" border="0" role="presentation" width="57" class="r9-o" style="table-layout: fixed; width: 57px;">
<tr class="nl2go-responsive-hide">
<td height="15" style="font-size: 15px; line-height: 15px;">­</td>
</tr>
<tr>
<td class="r19-i" style="font-size: 0px; line-height: 0px;"> <img src="https://ik.imagekit.io/w2okwbtu2/115727700_n9t8rrnwT.png?ik-sdk-version=javascript-1.4.3&updatedAt=1670872429989" width="57" border="0" class="" style="display: block; width: 100%;"></td>
</tr>
<tr class="nl2go-responsive-hide">
<td height="15" style="font-size: 15px; line-height: 15px;">­</td>
</tr>
</table>
</td>
</tr>
<tr>
<td class="r11-c" align="left">
<table cellspacing="0" cellpadding="0" border="0" role="presentation" width="100%" class="r12-o" style="table-layout: fixed; width: 100%;">
<tr>
<td align="center" valign="top" class="r20-i nl2go-default-textstyle" style="font-family: arial,helvetica,sans-serif; color: #3b3f44; font-size: 18px; line-height: 1.5; text-align: center;">
<div>
<p style="margin: 0; font-size: 14px;">Proudly made on <strong>Planet Earth 🌍</strong>.</p>
</div>
</td>
</tr>
</table>
</td>
</tr>
<tr>
<td class="r21-c" align="center">
<table cellspacing="0" cellpadding="0" border="0" role="presentation" width="570" class="r9-o" style="table-layout: fixed; width: 570px;">
<!-- -->
<tr>
<td valign="top" class="">
<table width="100%" cellspacing="0" cellpadding="0" border="0" role="presentation">
<tr>
<td class="r22-c" style="display: inline-block;">
<table cellspacing="0" cellpadding="0" border="0" role="presentation" width="570" class="r7-o" style="table-layout: fixed; width: 570px;">
<!-- -->
<tr class="nl2go-responsive-hide">
<td height="15" width="209" style="font-size: 15px; line-height: 15px;">­ </td>
<td height="15" style="font-size: 15px; line-height: 15px;">­</td>
<td height="15" width="209" style="font-size: 15px; line-height: 15px;">­ </td>
</tr>
<tr>
<td class="nl2go-responsive-hide" width="209" style="font-size: 0px; line-height: 1px;">­ </td>
<td class="r23-i">
<table width="100%" cellspacing="0" cellpadding="0" border="0" role="presentation">
<tr>
<th width="40" valign="" class="r24-c mobshow resp-table" style="font-weight: normal;">
<table cellspacing="0" cellpadding="0" border="0" role="presentation" width="100%" class="r25-o" style="table-layout: fixed; width: 100%;">
<!-- -->
<tr class="nl2go-responsive-hide">
<td height="5" style="font-size: 5px; line-height: 5px;">­</td>
<td height="5" width="8" style="font-size: 5px; line-height: 5px;">­ </td>
</tr>
<tr>
<td class="r26-i" style="font-size: 0px; line-height: 0px;"> <a href="https://github.com/makeplane/plane" target="_blank" style="color: #0092ff; text-decoration: underline;"> <img src="https://sendinblue-templates.s3.eu-west-3.amazonaws.com/icons/rounded_colored/github_32px.png" width="32" border="0" class="" style="display: block; width: 100%;"></a> </td>
<td class="nl2go-responsive-hide" width="8" style="font-size: 0px; line-height: 1px;">­ </td>
</tr>
<tr class="nl2go-responsive-hide">
<td height="5" style="font-size: 5px; line-height: 5px;">­</td>
<td height="5" width="8" style="font-size: 5px; line-height: 5px;">­ </td>
</tr>
</table>
</th>
<th width="40" valign="" class="r24-c mobshow resp-table" style="font-weight: normal;">
<table cellspacing="0" cellpadding="0" border="0" role="presentation" width="100%" class="r25-o" style="table-layout: fixed; width: 100%;">
<!-- -->
<tr class="nl2go-responsive-hide">
<td height="5" style="font-size: 5px; line-height: 5px;">­</td>
<td height="5" width="8" style="font-size: 5px; line-height: 5px;">­ </td>
</tr>
<tr>
<td class="r26-i" style="font-size: 0px; line-height: 0px;"> <a href="https://discord.gg/A92xrEGCge" target="_blank" style="color: #0092ff; text-decoration: underline;"> <img src="https://sendinblue-templates.s3.eu-west-3.amazonaws.com/icons/rounded_colored/discord_32px.png" width="32" border="0" class="" style="display: block; width: 100%;"></a> </td>
<td class="nl2go-responsive-hide" width="8" style="font-size: 0px; line-height: 1px;">­ </td>
</tr>
<tr class="nl2go-responsive-hide">
<td height="5" style="font-size: 5px; line-height: 5px;">­</td>
<td height="5" width="8" style="font-size: 5px; line-height: 5px;">­ </td>
</tr>
</table>
</th>
<th width="40" valign="" class="r24-c mobshow resp-table" style="font-weight: normal;">
<table cellspacing="0" cellpadding="0" border="0" role="presentation" width="100%" class="r25-o" style="table-layout: fixed; width: 100%;">
<!-- -->
<tr class="nl2go-responsive-hide">
<td height="5" style="font-size: 5px; line-height: 5px;">­</td>
<td height="5" width="8" style="font-size: 5px; line-height: 5px;">­ </td>
</tr>
<tr>
<td class="r26-i" style="font-size: 0px; line-height: 0px;"> <a href="https://twitter.com/planepowers" target="_blank" style="color: #0092ff; text-decoration: underline;"> <img src="https://sendinblue-templates.s3.eu-west-3.amazonaws.com/icons/rounded_colored/twitter_32px.png" width="32" border="0" class="" style="display: block; width: 100%;"></a> </td>
<td class="nl2go-responsive-hide" width="8" style="font-size: 0px; line-height: 1px;">­ </td>
</tr>
<tr class="nl2go-responsive-hide">
<td height="5" style="font-size: 5px; line-height: 5px;">­</td>
<td height="5" width="8" style="font-size: 5px; line-height: 5px;">­ </td>
</tr>
</table>
</th>
<th width="32" valign="" class="r24-c mobshow resp-table" style="font-weight: normal;">
<table cellspacing="0" cellpadding="0" border="0" role="presentation" width="100%" class="r27-o" style="table-layout: fixed; width: 100%;">
<!-- -->
<tr class="nl2go-responsive-hide">
<td height="5" style="font-size: 5px; line-height: 5px;">­</td>
</tr>
<tr>
<td class="r26-i" style="font-size: 0px; line-height: 0px;"> <a href="https://www.linkedin.com/company/planepowers/" target="_blank" style="color: #0092ff; text-decoration: underline;"> <img src="https://sendinblue-templates.s3.eu-west-3.amazonaws.com/icons/rounded_colored/linkedin_32px.png" width="32" border="0" class="" style="display: block; width: 100%;"></a> </td>
</tr>
<tr class="nl2go-responsive-hide">
<td height="5" style="font-size: 5px; line-height: 5px;">­</td>
</tr>
</table>
</th>
</tr>
</table>
</td>
<td class="nl2go-responsive-hide" width="209" style="font-size: 0px; line-height: 1px;">­ </td>
</tr>
<tr class="nl2go-responsive-hide">
<td height="15" width="209" style="font-size: 15px; line-height: 15px;">­ </td>
<td height="15" style="font-size: 15px; line-height: 15px;">­</td>
<td height="15" width="209" style="font-size: 15px; line-height: 15px;">­ </td>
</tr>
</table>
</td>
</tr>
</table>
</td>
</tr>
</table>
</td>
</tr>
<tr>
<td class="r11-c" align="left">
<table cellspacing="0" cellpadding="0" border="0" role="presentation" width="100%" class="r12-o" style="table-layout: fixed; width: 100%;">
<tr class="nl2go-responsive-hide">
<td height="15" style="font-size: 15px; line-height: 15px;">­</td>
</tr>
<tr>
<td align="center" valign="top" class="r28-i nl2go-default-textstyle" style="font-family: arial,helvetica,sans-serif; color: #3b3f44; font-size: 18px; line-height: 1.5; text-align: center;">
<div>
<p style="margin: 0; font-size: 14px;"><a href="{{ mirror }}" style="color: #0092ff; text-decoration: underline;">View in browser</a> | <a href="{{ unsubscribe }}" style="color: #0092ff; text-decoration: underline;">Unsubscribe</a></p>
</div>
</td>
</tr>
<tr class="nl2go-responsive-hide">
<td height="15" style="font-size: 15px; line-height: 15px;">­</td>
</tr>
</table>
</td>
</tr>
</table>
</td>
<td class="nl2go-responsive-hide" width="15" style="font-size: 0px; line-height: 1px;">­ </td>
</tr>
</table>
</th>
</tr>
</table>
</td>
</tr>
<tr class="nl2go-responsive-hide">
<td height="20" style="font-size: 20px; line-height: 20px; background-color: #eff2f7;">­</td>
</tr>
</table>
</td>
</tr>
</table>
</td>
</tr>
</table>
</td>
</tr>
</table>
</body>
</html>

View File

@ -1,4 +1,4 @@
NEXT_PUBLIC_API_BASE_URL = "<-- endpoint goes here -->"
NEXT_PUBLIC_GOOGLE_CLIENTID = "<-- google client id goes here -->"
NEXT_PUBLIC_GITHUB_ID = "<-- github id goes here -->"
NEXT_PUBLIC_APP_ENVIRONMENT=development
NEXT_PUBLIC_APP_ENVIRONMENT="<-- production | development -->"

View File

@ -6,17 +6,32 @@ WORKDIR /app
RUN apk add curl
RUN curl -fsSL "https://github.com/pnpm/pnpm/releases/latest/download/pnpm-linuxstatic-x64" -o /bin/pnpm; chmod +x /bin/pnpm;
ENV PNPM_HOME="pnpm"
ENV PATH="${PATH}:./pnpm"
COPY ./apps ./apps
COPY ./package.json ./package.json
COPY ./.eslintrc.json ./.eslintrc.json
COPY ./yarn.lock ./yarn.lock
COPY ./turbo.json ./turbo.json
COPY ./pnpm-workspace.yaml ./pnpm-workspace.yaml
COPY ./pnpm-lock.yaml ./pnpm-lock.yaml
RUN yarn global add turbo
RUN pnpm add -g turbo
RUN turbo prune --scope=app --docker
# Add lockfile and package.json's of isolated subworkspace
FROM node:18-alpine AS installer
RUN apk add curl
RUN curl -fsSL "https://github.com/pnpm/pnpm/releases/latest/download/pnpm-linuxstatic-x64" -o /bin/pnpm; chmod +x /bin/pnpm;
ENV PNPM_HOME="pnpm"
ENV PATH="${PATH}:./pnpm"
RUN apk add --no-cache libc6-compat
RUN apk update
WORKDIR /app
@ -24,14 +39,14 @@ WORKDIR /app
# First install the dependencies (as they change less often)
COPY .gitignore .gitignore
COPY --from=builder /app/out/json/ .
COPY --from=builder /app/out/yarn.lock ./yarn.lock
RUN yarn install
COPY --from=builder /app/out/pnpm-lock.yaml ./pnpm-lock.yaml
RUN pnpm install
# Build the project
COPY --from=builder /app/out/full/ .
COPY turbo.json turbo.json
RUN yarn turbo run build --filter=app...
RUN pnpm turbo run build --filter=app...
FROM node:18-alpine AS runner
WORKDIR /app

View File

@ -1,20 +1,27 @@
// react
// TODO: Refactor this component: into a different file, use this file to export the components
import React, { useState, useCallback, useEffect } from "react";
// next
import { useRouter } from "next/router";
// swr
import useSWR from "swr";
// hooks
import useUser from "lib/hooks/useUser";
import useTheme from "lib/hooks/useTheme";
import useToast from "lib/hooks/useToast";
import useUser from "lib/hooks/useUser";
// services
import userService from "lib/services/user.service";
// components
import ShortcutsModal from "components/command-palette/shortcuts";
import CreateProjectModal from "components/project/create-project-modal";
import { CreateProjectModal } from "components/project";
import CreateUpdateIssuesModal from "components/project/issues/create-update-issue-modal";
import CreateUpdateCycleModal from "components/project/cycles/create-update-cycle-modal";
import CreateUpdateModuleModal from "components/project/modules/create-update-module-modal";
import BulkDeleteIssuesModal from "components/common/bulk-delete-issues-modal";
// headless ui
import { Combobox, Dialog, Transition } from "@headlessui/react";
// constants
import { USER_ISSUE } from "constants/fetch-keys";
// ui
import { Button } from "ui";
// icons
@ -37,27 +44,37 @@ const CommandPalette: React.FC = () => {
const [isProjectModalOpen, setIsProjectModalOpen] = useState(false);
const [isShortcutsModalOpen, setIsShortcutsModalOpen] = useState(false);
const [isCreateCycleModalOpen, setIsCreateCycleModalOpen] = useState(false);
const [isCreateModuleModalOpen, setisCreateModuleModalOpen] = useState(false);
const [isCreateModuleModalOpen, setIsCreateModuleModalOpen] = useState(false);
const [isBulkDeleteIssuesModalOpen, setIsBulkDeleteIssuesModalOpen] = useState(false);
const { activeProject, issues } = useUser();
const router = useRouter();
const { workspaceSlug, projectId } = router.query;
const { user } = useUser();
const { setToastAlert } = useToast();
const { toggleCollapsed } = useTheme();
const { setToastAlert } = useToast();
const { data: myIssues } = useSWR<IIssue[]>(
workspaceSlug ? USER_ISSUE(workspaceSlug as string) : null,
workspaceSlug ? () => userService.userIssues(workspaceSlug as string) : null
);
const filteredIssues: IIssue[] =
query === ""
? issues?.results ?? []
: issues?.results.filter((issue) => issue.name.toLowerCase().includes(query.toLowerCase())) ??
[];
? myIssues ?? []
: myIssues?.filter(
(issue) =>
issue.name.toLowerCase().includes(query.toLowerCase()) ||
`${issue.project_detail.identifier}-${issue.sequence_id}`
.toLowerCase()
.includes(query.toLowerCase())
) ?? [];
const quickActions = [
{
name: "Add new issue...",
icon: RectangleStackIcon,
hide: !projectId,
shortcut: "I",
onClick: () => {
setIsIssueModalOpen(true);
@ -66,6 +83,7 @@ const CommandPalette: React.FC = () => {
{
name: "Add new project...",
icon: ClipboardDocumentListIcon,
hide: !workspaceSlug,
shortcut: "P",
onClick: () => {
setIsProjectModalOpen(true);
@ -100,7 +118,7 @@ const CommandPalette: React.FC = () => {
setIsCreateCycleModalOpen(true);
} else if ((e.ctrlKey || e.metaKey) && e.key === "m") {
e.preventDefault();
setisCreateModuleModalOpen(true);
setIsCreateModuleModalOpen(true);
} else if ((e.ctrlKey || e.metaKey) && e.key === "d") {
e.preventDefault();
setIsBulkDeleteIssuesModalOpen(true);
@ -133,28 +151,32 @@ const CommandPalette: React.FC = () => {
return () => document.removeEventListener("keydown", handleKeyDown);
}, [handleKeyDown]);
if (!user) return null;
return (
<>
<ShortcutsModal isOpen={isShortcutsModalOpen} setIsOpen={setIsShortcutsModalOpen} />
<CreateProjectModal isOpen={isProjectModalOpen} setIsOpen={setIsProjectModalOpen} />
{activeProject && (
{workspaceSlug && (
<CreateProjectModal isOpen={isProjectModalOpen} setIsOpen={setIsProjectModalOpen} />
)}
{projectId && (
<>
<CreateUpdateCycleModal
isOpen={isCreateCycleModalOpen}
setIsOpen={setIsCreateCycleModalOpen}
projectId={activeProject.id}
projectId={projectId as string}
/>
<CreateUpdateModuleModal
isOpen={isCreateModuleModalOpen}
setIsOpen={setisCreateModuleModalOpen}
projectId={activeProject.id}
setIsOpen={setIsCreateModuleModalOpen}
projectId={projectId as string}
/>
</>
)}
<CreateUpdateIssuesModal
isOpen={isIssueModalOpen}
setIsOpen={setIsIssueModalOpen}
projectId={activeProject?.id}
projectId={projectId as string}
/>
<BulkDeleteIssuesModal
isOpen={isBulkDeleteIssuesModalOpen}
@ -190,147 +212,151 @@ const CommandPalette: React.FC = () => {
leaveTo="opacity-0 scale-95"
>
<Dialog.Panel className="relative mx-auto max-w-2xl transform divide-y divide-gray-500 divide-opacity-10 rounded-xl bg-white bg-opacity-80 shadow-2xl ring-1 ring-black ring-opacity-5 backdrop-blur backdrop-filter transition-all">
<form>
<Combobox>
<div className="relative m-1">
<MagnifyingGlassIcon
className="pointer-events-none absolute top-3.5 left-4 h-5 w-5 text-gray-900 text-opacity-40"
aria-hidden="true"
/>
<Combobox.Input
className="h-12 w-full border-0 bg-transparent pl-11 pr-4 text-gray-900 placeholder-gray-500 focus:ring-0 sm:text-sm outline-none"
placeholder="Search..."
onChange={(event) => setQuery(event.target.value)}
/>
</div>
<Combobox
onChange={(value: any) => {
if (value?.url) router.push(value.url);
else if (value?.onClick) value.onClick();
handleCommandPaletteClose();
}}
>
<div className="relative m-1">
<MagnifyingGlassIcon
className="pointer-events-none absolute top-3.5 left-4 h-5 w-5 text-gray-900 text-opacity-40"
aria-hidden="true"
/>
<Combobox.Input
className="h-12 w-full border-0 bg-transparent pl-11 pr-4 text-gray-900 placeholder-gray-500 outline-none focus:ring-0 sm:text-sm"
placeholder="Search..."
autoComplete="off"
onChange={(event) => setQuery(event.target.value)}
/>
</div>
<Combobox.Options
static
className="max-h-80 scroll-py-2 divide-y divide-gray-500 divide-opacity-10 overflow-y-auto"
>
{filteredIssues.length > 0 && (
<>
<li className="p-2">
{query === "" && (
<h2 className="mt-4 mb-2 px-3 text-xs font-semibold text-gray-900">
Select issues
</h2>
)}
<ul className="text-sm text-gray-700">
{filteredIssues.map((issue) => (
<Combobox.Option
key={issue.id}
as="label"
htmlFor={`issue-${issue.id}`}
value={{
name: issue.name,
url: `/projects/${issue.project}/issues/${issue.id}`,
}}
className={({ active }) =>
classNames(
"flex items-center justify-between cursor-pointer select-none rounded-md px-3 py-2",
active ? "bg-gray-900 bg-opacity-5 text-gray-900" : ""
)
}
>
{({ active }) => (
<>
<div className="flex items-center gap-2">
<span
className="flex-shrink-0 h-1.5 w-1.5 block rounded-full"
style={{
backgroundColor: issue.state_detail.color,
}}
/>
<span className="flex-shrink-0 text-xs text-gray-500">
{activeProject?.identifier}-{issue.sequence_id}
</span>
<span>{issue.name}</span>
</div>
{active && (
<button
type="button"
onClick={() => {
router.push(
`/projects/${activeProject?.id}/issues/${issue.id}`
);
handleCommandPaletteClose();
}}
className="flex-shrink-0 text-gray-500"
>
Jump to...
</button>
)}
</>
)}
</Combobox.Option>
))}
</ul>
</li>
</>
)}
{query === "" && (
<Combobox.Options
static
className="max-h-80 scroll-py-2 divide-y divide-gray-500 divide-opacity-10 overflow-y-auto"
>
{filteredIssues.length > 0 && (
<>
<li className="p-2">
<h2 className="sr-only">Quick actions</h2>
{query === "" && (
<h2 className="mt-4 mb-2 px-3 text-xs font-semibold text-gray-900">
Select issues
</h2>
)}
<ul className="text-sm text-gray-700">
{quickActions.map((action) => (
{filteredIssues.map((issue) => (
<Combobox.Option
key={action.shortcut}
key={issue.id}
as="label"
htmlFor={`issue-${issue.id}`}
value={{
name: action.name,
onClick: action.onClick,
name: issue.name,
url: `/${workspaceSlug}/projects/${issue.project}/issues/${issue.id}`,
}}
className={({ active }) =>
classNames(
"flex cursor-default select-none items-center rounded-md px-3 py-2",
active ? "bg-gray-900 bg-opacity-5 text-gray-900" : ""
"flex cursor-pointer select-none items-center justify-between rounded-md px-3 py-2",
active ? "bg-gray-500 bg-opacity-5 text-gray-900" : ""
)
}
>
{({ active }) => (
<>
<action.icon
className={classNames(
"h-6 w-6 flex-none text-gray-900 text-opacity-40",
active ? "text-opacity-100" : ""
)}
aria-hidden="true"
/>
<span className="ml-3 flex-auto truncate">{action.name}</span>
<span className="ml-3 flex-none text-xs font-semibold text-gray-500">
<kbd className="font-sans"></kbd>
<kbd className="font-sans">{action.shortcut}</kbd>
</span>
<div className="flex items-center gap-2">
<span
className="block h-1.5 w-1.5 flex-shrink-0 rounded-full"
style={{
backgroundColor: issue.state_detail.color,
}}
/>
<span className="flex-shrink-0 text-xs text-gray-500">
{issue.project_detail?.identifier}-{issue.sequence_id}
</span>
<span>{issue.name}</span>
</div>
<button
type="button"
className={`flex-shrink-0 rounded-md border border-gray-300 px-2 py-1 text-sm text-gray-500 transition-opacity duration-75 hover:bg-gray-500 hover:bg-opacity-5 ${
active
? "pointer-events-auto opacity-100"
: "pointer-events-none opacity-0"
}`}
>
Jump to
</button>
</>
)}
</Combobox.Option>
))}
</ul>
</li>
)}
</Combobox.Options>
{query !== "" && filteredIssues.length === 0 && (
<div className="py-14 px-6 text-center sm:px-14">
<FolderIcon
className="mx-auto h-6 w-6 text-gray-900 text-opacity-40"
aria-hidden="true"
/>
<p className="mt-4 text-sm text-gray-900">
We couldn{"'"}t find any issue with that term. Please try again.
</p>
</div>
</>
)}
</Combobox>
{query === "" && (
<li className="p-2">
<h2 className="sr-only">Quick actions</h2>
<ul className="text-sm text-gray-700">
{quickActions.map(
(action) =>
!action.hide && (
<Combobox.Option
key={action.shortcut}
value={{
name: action.name,
onClick: action.onClick,
}}
className={({ active }) =>
classNames(
"flex cursor-default select-none items-center rounded-md px-3 py-2",
active ? "bg-gray-500 bg-opacity-5 text-gray-900" : ""
)
}
>
{({ active }) => (
<>
<action.icon
className={classNames(
"h-6 w-6 flex-none text-gray-900 text-opacity-40",
active ? "text-opacity-100" : ""
)}
aria-hidden="true"
/>
<span className="ml-3 flex-auto truncate">{action.name}</span>
<span className="ml-3 flex-none text-xs font-semibold text-gray-500">
<kbd className="font-sans"></kbd>
<kbd className="font-sans">{action.shortcut}</kbd>
</span>
</>
)}
</Combobox.Option>
)
)}
</ul>
</li>
)}
</Combobox.Options>
<div className="flex justify-end items-center gap-2 p-3">
<div>
<Button type="button" size="sm" onClick={handleCommandPaletteClose}>
Close
</Button>
{query !== "" && filteredIssues.length === 0 && (
<div className="py-14 px-6 text-center sm:px-14">
<FolderIcon
className="mx-auto h-6 w-6 text-gray-500 text-opacity-40"
aria-hidden="true"
/>
<p className="mt-4 text-sm text-gray-900">
We couldn{"'"}t find any issue with that term. Please try again.
</p>
</div>
)}
</Combobox>
<div className="flex items-center justify-end gap-2 p-3">
<div>
<Button type="button" size="sm" onClick={handleCommandPaletteClose}>
Close
</Button>
</div>
</form>
</div>
</Dialog.Panel>
</Transition.Child>
</div>

View File

@ -1,16 +1,28 @@
import React from "react";
// next
import Link from "next/link";
import Image from "next/image";
import { useRouter } from "next/router";
// swr
import useSWR from "swr";
// react-beautiful-dnd
import { DraggableStateSnapshot } from "react-beautiful-dnd";
// headless ui
import { Listbox, Transition } from "@headlessui/react";
// constants
import { PRIORITIES } from "constants/";
import { PROJECT_ISSUES_LIST, STATE_LIST } from "constants/fetch-keys";
import { getPriorityIcon } from "constants/global";
// services
import issuesService from "lib/services/issues.service";
import stateService from "lib/services/state.service";
import projectService from "lib/services/project.service";
// icons
import { TrashIcon } from "@heroicons/react/24/outline";
import { CalendarDaysIcon } from "@heroicons/react/20/solid";
import User from "public/user.png";
// types
import { IIssue, IWorkspaceMember, Properties } from "types";
import { IIssue, IssueResponse, IWorkspaceMember, Properties } from "types";
// common
import {
addSpaceIfCamelCase,
@ -18,11 +30,7 @@ import {
findHowManyDaysLeft,
renderShortNumericDateFormat,
} from "constants/common";
// constants
import { PRIORITIES } from "constants/";
import useUser from "lib/hooks/useUser";
import React from "react";
import { getPriorityIcon } from "constants/global";
import { PROJECT_DETAILS } from "constants/fetch-keys";
type Props = {
issue: IIssue;
@ -38,7 +46,7 @@ type Props = {
partialUpdateIssue: (formData: Partial<IIssue>, childIssueId: string) => void;
};
const SingleIssue: React.FC<Props> = ({
const SingleBoardIssue: React.FC<Props> = ({
issue,
properties,
snapshot,
@ -47,42 +55,68 @@ const SingleIssue: React.FC<Props> = ({
handleDeleteIssue,
partialUpdateIssue,
}) => {
const { activeProject, states } = useUser();
const router = useRouter();
const { workspaceSlug, projectId } = router.query;
const { data: issues } = useSWR<IssueResponse>(
workspaceSlug && projectId
? PROJECT_ISSUES_LIST(workspaceSlug as string, projectId as string)
: null,
workspaceSlug && projectId
? () => issuesService.getIssues(workspaceSlug as string, projectId as string)
: null
);
const { data: states } = useSWR(
workspaceSlug && projectId ? STATE_LIST(projectId as string) : null,
workspaceSlug && projectId
? () => stateService.getStates(workspaceSlug as string, projectId as string)
: null
);
const { data: projectDetails } = useSWR(
workspaceSlug && projectId ? PROJECT_DETAILS(projectId as string) : null,
workspaceSlug && projectId
? () => projectService.getProject(workspaceSlug as string, projectId as string)
: null
);
const totalChildren = issues?.results.filter((i) => i.parent === issue.id).length;
return (
<div
className={`border rounded bg-white shadow-sm ${
snapshot && snapshot.isDragging ? "border-theme shadow-lg bg-indigo-50" : ""
className={`rounded border bg-white shadow-sm ${
snapshot && snapshot.isDragging ? "border-theme bg-indigo-50 shadow-lg" : ""
}`}
>
<div className="group/card relative p-2 select-none">
<div className="group/card relative select-none p-2">
{handleDeleteIssue && (
<div className="opacity-0 group-hover/card:opacity-100 absolute top-1.5 right-1.5 z-10">
<div className="absolute top-1.5 right-1.5 z-10 opacity-0 group-hover/card:opacity-100">
<button
type="button"
className="h-7 w-7 p-1 grid place-items-center rounded text-red-500 bg-white hover:bg-red-50 duration-300 outline-none"
className="grid h-7 w-7 place-items-center rounded bg-white p-1 text-red-500 outline-none duration-300 hover:bg-red-50"
onClick={() => handleDeleteIssue(issue.id)}
>
<TrashIcon className="h-4 w-4" />
</button>
</div>
)}
<Link href={`/projects/${issue.project}/issues/${issue.id}`}>
<Link href={`/${workspaceSlug}/projects/${issue.project}/issues/${issue.id}`}>
<a>
{properties.key && (
<div className="text-xs font-medium text-gray-500 mb-2">
{activeProject?.identifier}-{issue.sequence_id}
<div className="mb-2 text-xs font-medium text-gray-500">
{projectDetails?.identifier}-{issue.sequence_id}
</div>
)}
<h5
className="group-hover:text-theme text-sm mb-3"
className="mb-3 text-sm group-hover:text-theme"
style={{ lineClamp: 3, WebkitLineClamp: 3 }}
>
{issue.name}
</h5>
</a>
</Link>
<div className="flex items-center gap-x-1 gap-y-2 text-xs flex-wrap">
<div className="flex flex-wrap items-center gap-x-1 gap-y-2 text-xs">
{properties.priority && (
<Listbox
as="div"
@ -96,7 +130,7 @@ const SingleIssue: React.FC<Props> = ({
<>
<div>
<Listbox.Button
className={`grid place-items-center rounded shadow-sm px-2 py-1 cursor-pointer focus:outline-none focus:ring-1 focus:ring-indigo-500 focus:border-indigo-500 capitalize ${
className={`grid cursor-pointer place-items-center rounded px-2 py-1 capitalize shadow-sm focus:border-indigo-500 focus:outline-none focus:ring-1 focus:ring-indigo-500 ${
issue.priority === "urgent"
? "bg-red-100 text-red-600"
: issue.priority === "high"
@ -118,14 +152,14 @@ const SingleIssue: React.FC<Props> = ({
leaveFrom="opacity-100"
leaveTo="opacity-0"
>
<Listbox.Options className="absolute z-20 mt-1 bg-white shadow-lg max-h-28 rounded-md py-1 text-xs ring-1 ring-black ring-opacity-5 overflow-auto focus:outline-none">
<Listbox.Options className="absolute z-20 mt-1 max-h-28 overflow-auto rounded-md bg-white py-1 text-xs shadow-lg ring-1 ring-black ring-opacity-5 focus:outline-none">
{PRIORITIES?.map((priority) => (
<Listbox.Option
key={priority}
className={({ active }) =>
classNames(
active ? "bg-indigo-50" : "bg-white",
"flex items-center gap-2 cursor-pointer capitalize select-none px-3 py-2"
"flex cursor-pointer select-none items-center gap-2 px-3 py-2 capitalize"
)
}
value={priority}
@ -137,24 +171,6 @@ const SingleIssue: React.FC<Props> = ({
</Listbox.Options>
</Transition>
</div>
{/* <div className="absolute bottom-full right-0 mb-2 z-10 hidden group-hover:block p-2 bg-white shadow-md rounded-md whitespace-nowrap">
<h5 className="font-medium mb-1 text-gray-900">Priority</h5>
<div
className={`capitalize ${
issue.priority === "urgent"
? "text-red-600"
: issue.priority === "high"
? "text-orange-500"
: issue.priority === "medium"
? "text-yellow-500"
: issue.priority === "low"
? "text-green-500"
: ""
}`}
>
{issue.priority ?? "None"}
</div>
</div> */}
</>
)}
</Listbox>
@ -171,9 +187,9 @@ const SingleIssue: React.FC<Props> = ({
{({ open }) => (
<>
<div>
<Listbox.Button className="flex items-center gap-1 hover:bg-gray-100 border rounded shadow-sm px-2 py-1 cursor-pointer focus:outline-none focus:ring-1 focus:ring-indigo-500 focus:border-indigo-500 text-xs duration-300">
<Listbox.Button className="flex cursor-pointer items-center gap-1 rounded border px-2 py-1 text-xs shadow-sm duration-300 hover:bg-gray-100 focus:border-indigo-500 focus:outline-none focus:ring-1 focus:ring-indigo-500">
<span
className="flex-shrink-0 h-1.5 w-1.5 rounded-full"
className="h-1.5 w-1.5 flex-shrink-0 rounded-full"
style={{
backgroundColor: issue.state_detail.color,
}}
@ -188,20 +204,20 @@ const SingleIssue: React.FC<Props> = ({
leaveFrom="opacity-100"
leaveTo="opacity-0"
>
<Listbox.Options className="absolute z-20 mt-1 bg-white shadow-lg max-h-28 rounded-md py-1 text-xs ring-1 ring-black ring-opacity-5 overflow-auto focus:outline-none">
<Listbox.Options className="absolute z-20 mt-1 max-h-28 overflow-auto rounded-md bg-white py-1 text-xs shadow-lg ring-1 ring-black ring-opacity-5 focus:outline-none">
{states?.map((state) => (
<Listbox.Option
key={state.id}
className={({ active }) =>
classNames(
active ? "bg-indigo-50" : "bg-white",
"flex items-center gap-2 cursor-pointer select-none px-3 py-2"
"flex cursor-pointer select-none items-center gap-2 px-3 py-2"
)
}
value={state.id}
>
<span
className="flex-shrink-0 h-1.5 w-1.5 rounded-full"
className="h-1.5 w-1.5 flex-shrink-0 rounded-full"
style={{
backgroundColor: state.color,
}}
@ -220,19 +236,9 @@ const SingleIssue: React.FC<Props> = ({
)}
</Listbox>
)}
{properties.start_date && (
<div className="group flex-shrink-0 flex items-center gap-1 hover:bg-gray-100 border rounded shadow-sm px-2 py-1 cursor-pointer focus:outline-none focus:ring-1 focus:ring-indigo-500 focus:border-indigo-500 text-xs duration-300">
<CalendarDaysIcon className="h-4 w-4" />
{issue.start_date ? renderShortNumericDateFormat(issue.start_date) : "N/A"}
{/* <div className="fixed -translate-y-3/4 z-10 hidden group-hover:block p-2 bg-white shadow-md rounded-md whitespace-nowrap">
<h5 className="font-medium mb-1">Started at</h5>
<div>{renderShortNumericDateFormat(issue.start_date ?? "")}</div>
</div> */}
</div>
)}
{properties.due_date && (
<div
className={`group flex-shrink-0 group flex items-center gap-1 hover:bg-gray-100 border rounded shadow-sm px-2 py-1 cursor-pointer focus:outline-none focus:ring-1 focus:ring-indigo-500 focus:border-indigo-500 text-xs duration-300 ${
className={`group flex flex-shrink-0 cursor-pointer items-center gap-1 rounded border px-2 py-1 text-xs shadow-sm duration-300 hover:bg-gray-100 focus:border-indigo-500 focus:outline-none focus:ring-1 focus:ring-indigo-500 ${
issue.target_date === null
? ""
: issue.target_date < new Date().toISOString()
@ -242,18 +248,11 @@ const SingleIssue: React.FC<Props> = ({
>
<CalendarDaysIcon className="h-4 w-4" />
{issue.target_date ? renderShortNumericDateFormat(issue.target_date) : "N/A"}
{/* <div className="fixed -translate-y-3/4 z-10 hidden group-hover:block p-2 bg-white shadow-md rounded-md whitespace-nowrap">
<h5 className="font-medium mb-1 text-gray-900">Target date</h5>
<div>{renderShortNumericDateFormat(issue.target_date ?? "")}</div>
<div>
{issue.target_date &&
(issue.target_date < new Date().toISOString()
? `Due date has passed by ${findHowManyDaysLeft(issue.target_date)} days`
: findHowManyDaysLeft(issue.target_date) <= 3
? `Due date is in ${findHowManyDaysLeft(issue.target_date)} days`
: "Due date")}
</div>
</div> */}
</div>
)}
{properties.sub_issue_count && (
<div className="flex flex-shrink-0 items-center gap-1 rounded border px-2 py-1 text-xs shadow-sm duration-300 hover:bg-gray-100 focus:border-indigo-500 focus:outline-none focus:ring-1 focus:ring-indigo-500">
{totalChildren} {totalChildren === 1 ? "sub-issue" : "sub-issues"}
</div>
)}
{properties.assignee && (
@ -275,7 +274,7 @@ const SingleIssue: React.FC<Props> = ({
<>
<div>
<Listbox.Button>
<div className="flex items-center gap-1 text-xs cursor-pointer">
<div className="flex cursor-pointer items-center gap-1 text-xs">
{assignees.length > 0 ? (
assignees.map((assignee, index: number) => (
<div
@ -285,17 +284,19 @@ const SingleIssue: React.FC<Props> = ({
}`}
>
{assignee.avatar && assignee.avatar !== "" ? (
<div className="h-5 w-5 border-2 bg-white border-white rounded-full">
<div className="h-5 w-5 rounded-full border-2 border-white bg-white">
<Image
src={assignee.avatar}
height="100%"
width="100%"
className="rounded-full"
alt={assignee?.first_name}
priority={false}
loading="lazy"
/>
</div>
) : (
<div className="h-5 w-5 bg-gray-700 text-white border-2 border-white grid place-items-center rounded-full capitalize">
<div className="grid h-5 w-5 place-items-center rounded-full border-2 border-white bg-gray-700 capitalize text-white">
{assignee.first_name && assignee.first_name !== ""
? assignee.first_name.charAt(0)
: assignee?.email?.charAt(0)}
@ -304,13 +305,15 @@ const SingleIssue: React.FC<Props> = ({
</div>
))
) : (
<div className="h-5 w-5 border-2 bg-white border-white rounded-full">
<div className="h-5 w-5 rounded-full border-2 border-white bg-white">
<Image
src={User}
height="100%"
width="100%"
className="rounded-full"
alt="No user"
priority={false}
loading="lazy"
/>
</div>
)}
@ -324,7 +327,7 @@ const SingleIssue: React.FC<Props> = ({
leaveFrom="opacity-100"
leaveTo="opacity-0"
>
<Listbox.Options className="absolute left-0 z-20 mt-1 bg-white shadow-lg max-h-28 rounded-md py-1 text-xs ring-1 ring-black ring-opacity-5 overflow-auto focus:outline-none">
<Listbox.Options className="absolute left-0 z-20 mt-1 max-h-28 overflow-auto rounded-md bg-white py-1 text-xs shadow-lg ring-1 ring-black ring-opacity-5 focus:outline-none">
{people?.map((person) => (
<Listbox.Option
key={person.id}
@ -355,10 +358,12 @@ const SingleIssue: React.FC<Props> = ({
className="rounded-full"
layout="fill"
objectFit="cover"
priority={false}
loading="lazy"
/>
</div>
) : (
<div className="h-4 w-4 bg-gray-700 text-white grid place-items-center capitalize rounded-full">
<div className="grid h-4 w-4 place-items-center rounded-full bg-gray-700 capitalize text-white">
{person.member.first_name && person.member.first_name !== ""
? person.member.first_name.charAt(0)
: person.member.email.charAt(0)}
@ -375,14 +380,6 @@ const SingleIssue: React.FC<Props> = ({
</Listbox.Options>
</Transition>
</div>
{/* <div className="absolute bottom-full left-0 mb-2 z-10 hidden group-hover:block p-2 bg-white shadow-md rounded-md whitespace-nowrap">
<h5 className="font-medium mb-1">Assigned to</h5>
<div>
{issue.assignee_details?.length > 0
? issue.assignee_details.map((assignee) => assignee.first_name).join(", ")
: "No one"}
</div>
</div> */}
</>
)}
</Listbox>
@ -393,4 +390,4 @@ const SingleIssue: React.FC<Props> = ({
);
};
export default SingleIssue;
export default SingleBoardIssue;

View File

@ -1,13 +1,15 @@
// react
import React, { useState } from "react";
// next
import { useRouter } from "next/router";
// swr
import { mutate } from "swr";
import useSWR, { mutate } from "swr";
// react hook form
import { SubmitHandler, useForm } from "react-hook-form";
// services
import issuesServices from "lib/services/issues.service";
import projectService from "lib/services/project.service";
// hooks
import useUser from "lib/hooks/useUser";
import useToast from "lib/hooks/useToast";
// headless ui
import { Combobox, Dialog, Transition } from "@headlessui/react";
@ -18,9 +20,10 @@ import { FolderIcon, MagnifyingGlassIcon } from "@heroicons/react/24/outline";
// types
import { IIssue, IssueResponse } from "types";
// fetch keys
import { PROJECT_ISSUES_LIST } from "constants/fetch-keys";
import { PROJECT_ISSUES_LIST, PROJECT_DETAILS } from "constants/fetch-keys";
// common
import { classNames } from "constants/common";
import { LayerDiagonalIcon } from "ui/icons";
type FormInput = {
issue_ids: string[];
@ -35,25 +38,45 @@ type Props = {
const BulkDeleteIssuesModal: React.FC<Props> = ({ isOpen, setIsOpen }) => {
const [query, setQuery] = useState("");
const { activeWorkspace, activeProject, issues } = useUser();
const router = useRouter();
const {
query: { workspaceSlug, projectId },
} = router;
const { data: issues } = useSWR(
workspaceSlug && projectId
? PROJECT_ISSUES_LIST(workspaceSlug as string, projectId as string)
: null,
workspaceSlug && projectId
? () => issuesServices.getIssues(workspaceSlug as string, projectId as string)
: null
);
const { data: projectDetails } = useSWR(
workspaceSlug && projectId ? PROJECT_DETAILS(projectId as string) : null,
workspaceSlug && projectId
? () => projectService.getProject(workspaceSlug as string, projectId as string)
: null
);
const { setToastAlert } = useToast();
const { register, handleSubmit, reset } = useForm<FormInput>();
const filteredIssues: IIssue[] =
query === ""
? issues?.results ?? []
: issues?.results.filter((issue) => issue.name.toLowerCase().includes(query.toLowerCase())) ??
[];
const { register, handleSubmit, reset } = useForm<FormInput>();
const handleClose = () => {
setIsOpen(false);
setQuery("");
reset();
};
const handleDelete: SubmitHandler<FormInput> = (data) => {
const handleDelete: SubmitHandler<FormInput> = async (data) => {
if (!data.issue_ids || data.issue_ids.length === 0) {
setToastAlert({
title: "Error",
@ -63,9 +86,11 @@ const BulkDeleteIssuesModal: React.FC<Props> = ({ isOpen, setIsOpen }) => {
return;
}
if (activeWorkspace && activeProject) {
issuesServices
.bulkDeleteIssues(activeWorkspace.slug, activeProject.id, data)
if (!Array.isArray(data.issue_ids)) data.issue_ids = [data.issue_ids];
if (workspaceSlug && projectId) {
await issuesServices
.bulkDeleteIssues(workspaceSlug as string, projectId as string, data)
.then((res) => {
setToastAlert({
title: "Success",
@ -73,7 +98,7 @@ const BulkDeleteIssuesModal: React.FC<Props> = ({ isOpen, setIsOpen }) => {
message: res.message,
});
mutate<IssueResponse>(
PROJECT_ISSUES_LIST(activeWorkspace.slug, activeProject.id),
PROJECT_ISSUES_LIST(workspaceSlug as string, projectId as string),
(prevData) => {
return {
...(prevData as IssueResponse),
@ -129,7 +154,7 @@ const BulkDeleteIssuesModal: React.FC<Props> = ({ isOpen, setIsOpen }) => {
aria-hidden="true"
/>
<Combobox.Input
className="h-12 w-full border-0 bg-transparent pl-11 pr-4 text-gray-900 placeholder-gray-500 focus:ring-0 sm:text-sm outline-none"
className="h-12 w-full border-0 bg-transparent pl-11 pr-4 text-gray-900 placeholder-gray-500 outline-none focus:ring-0 sm:text-sm"
placeholder="Search..."
onChange={(event) => setQuery(event.target.value)}
/>
@ -139,58 +164,63 @@ const BulkDeleteIssuesModal: React.FC<Props> = ({ isOpen, setIsOpen }) => {
static
className="max-h-80 scroll-py-2 divide-y divide-gray-500 divide-opacity-10 overflow-y-auto"
>
{filteredIssues.length > 0 && (
<>
<li className="p-2">
{query === "" && (
<h2 className="mt-4 mb-2 px-3 text-xs font-semibold text-gray-900">
Select issues
</h2>
)}
<ul className="text-sm text-gray-700">
{filteredIssues.map((issue) => (
<Combobox.Option
key={issue.id}
as="label"
htmlFor={`issue-${issue.id}`}
value={{
name: issue.name,
url: `/projects/${issue.project}/issues/${issue.id}`,
}}
className={({ active }) =>
classNames(
"flex items-center justify-between cursor-pointer select-none rounded-md px-3 py-2",
active ? "bg-gray-900 bg-opacity-5 text-gray-900" : ""
)
}
>
{({ active }) => (
<>
<div className="flex items-center gap-2">
<input
type="checkbox"
{...register("issue_ids")}
id={`issue-${issue.id}`}
value={issue.id}
/>
<span
className="flex-shrink-0 h-1.5 w-1.5 block rounded-full"
style={{
backgroundColor: issue.state_detail.color,
}}
/>
<span className="flex-shrink-0 text-xs text-gray-500">
{activeProject?.identifier}-{issue.sequence_id}
</span>
<span>{issue.name}</span>
</div>
</>
)}
</Combobox.Option>
))}
</ul>
</li>
</>
{filteredIssues.length > 0 ? (
<li className="p-2">
{query === "" && (
<h2 className="mt-4 mb-2 px-3 text-xs font-semibold text-gray-900">
Select issues to delete
</h2>
)}
<ul className="text-sm text-gray-700">
{filteredIssues.map((issue) => (
<Combobox.Option
key={issue.id}
as="label"
htmlFor={`issue-${issue.id}`}
value={{
name: issue.name,
url: `/${workspaceSlug}/projects/${issue.project}/issues/${issue.id}`,
}}
className={({ active }) =>
classNames(
"flex cursor-pointer select-none items-center justify-between rounded-md px-3 py-2",
active ? "bg-gray-900 bg-opacity-5 text-gray-900" : ""
)
}
>
<div className="flex items-center gap-2">
<input
type="checkbox"
{...register("issue_ids")}
id={`issue-${issue.id}`}
value={issue.id}
/>
<span
className="block h-1.5 w-1.5 flex-shrink-0 rounded-full"
style={{
backgroundColor: issue.state_detail.color,
}}
/>
<span className="flex-shrink-0 text-xs text-gray-500">
{projectDetails?.identifier}-{issue.sequence_id}
</span>
<span>{issue.name}</span>
</div>
</Combobox.Option>
))}
</ul>
</li>
) : (
<div className="flex flex-col items-center justify-center gap-4 px-3 py-8 text-center">
<LayerDiagonalIcon height="56" width="56" />
<h3 className="text-gray-500">
No issues found. Create a new issue with{" "}
<pre className="inline rounded bg-gray-100 px-2 py-1">
Ctrl/Command + I
</pre>
.
</h3>
</div>
)}
</Combobox.Options>
@ -207,16 +237,16 @@ const BulkDeleteIssuesModal: React.FC<Props> = ({ isOpen, setIsOpen }) => {
)}
</Combobox>
<div className="flex justify-end items-center gap-2 p-3">
<Button onClick={handleSubmit(handleDelete)} theme="danger" size="sm">
Delete selected issues
</Button>
<div>
<Button type="button" size="sm" onClick={handleClose}>
{filteredIssues.length > 0 && (
<div className="flex items-center justify-end gap-2 p-3">
<Button type="button" theme="secondary" size="sm" onClick={handleClose}>
Close
</Button>
<Button onClick={handleSubmit(handleDelete)} theme="danger" size="sm">
Delete selected issues
</Button>
</div>
</div>
)}
</form>
</Dialog.Panel>
</Transition.Child>

View File

@ -1,10 +1,14 @@
// react
import React, { useState } from "react";
import { useRouter } from "next/router";
import useSWR from "swr";
// react-hook-form
import { Controller, SubmitHandler, useForm } from "react-hook-form";
// hooks
import useUser from "lib/hooks/useUser";
import useToast from "lib/hooks/useToast";
// services
import projectService from "lib/services/project.service";
// headless ui
import { Combobox, Dialog, Transition } from "@headlessui/react";
// ui
@ -12,9 +16,12 @@ import { Button } from "ui";
// icons
import { MagnifyingGlassIcon, RectangleStackIcon } from "@heroicons/react/24/outline";
// types
import { IIssue, IssueResponse } from "types";
import { IIssue } from "types";
// fetch-keys
import { PROJECT_DETAILS } from "constants/fetch-keys";
// common
import { classNames } from "constants/common";
import { LayerDiagonalIcon } from "ui/icons";
type FormInput = {
issues: string[];
@ -37,7 +44,15 @@ const ExistingIssuesListModal: React.FC<Props> = ({
}) => {
const [query, setQuery] = useState("");
const { activeProject } = useUser();
const router = useRouter();
const { workspaceSlug, projectId } = router.query;
const { data: projectDetails } = useSWR(
workspaceSlug && projectId ? PROJECT_DETAILS(projectId as string) : null,
workspaceSlug && projectId
? () => projectService.getProject(workspaceSlug as string, projectId as string)
: null
);
const { setToastAlert } = useToast();
@ -80,7 +95,7 @@ const ExistingIssuesListModal: React.FC<Props> = ({
return (
<>
<Transition.Root show={isOpen} as={React.Fragment} afterLeave={() => setQuery("")} appear>
<Dialog as="div" className="relative z-10" onClose={handleClose}>
<Dialog as="div" className="relative z-20" onClose={handleClose}>
<Transition.Child
as={React.Fragment}
enter="ease-out duration-300"
@ -116,7 +131,7 @@ const ExistingIssuesListModal: React.FC<Props> = ({
aria-hidden="true"
/>
<Combobox.Input
className="h-12 w-full border-0 bg-transparent pl-11 pr-4 text-gray-900 placeholder-gray-500 focus:ring-0 sm:text-sm outline-none"
className="h-12 w-full border-0 bg-transparent pl-11 pr-4 text-gray-900 placeholder-gray-500 outline-none focus:ring-0 sm:text-sm"
placeholder="Search..."
onChange={(e) => setQuery(e.target.value)}
/>
@ -126,7 +141,7 @@ const ExistingIssuesListModal: React.FC<Props> = ({
static
className="max-h-80 scroll-py-2 divide-y divide-gray-500 divide-opacity-10 overflow-y-auto"
>
{filteredIssues.length > 0 && (
{filteredIssues.length > 0 ? (
<li className="p-2">
{query === "" && (
<h2 className="mt-4 mb-2 px-3 text-xs font-semibold text-gray-900">
@ -135,40 +150,50 @@ const ExistingIssuesListModal: React.FC<Props> = ({
)}
<ul className="text-sm text-gray-700">
{filteredIssues.map((issue) => {
if ((type === "cycle" && !issue.issue_cycle) || type === "module")
return (
<Combobox.Option
key={issue.id}
as="label"
htmlFor={`issue-${issue.id}`}
value={issue.id}
className={({ active }) =>
classNames(
"flex items-center gap-2 cursor-pointer select-none w-full rounded-md px-3 py-2",
active ? "bg-gray-900 bg-opacity-5 text-gray-900" : ""
)
}
>
{({ selected }) => (
<>
<input type="checkbox" checked={selected} readOnly />
<span
className="flex-shrink-0 h-1.5 w-1.5 block rounded-full"
style={{
backgroundColor: issue.state_detail.color,
}}
/>
<span className="flex-shrink-0 text-xs text-gray-500">
{activeProject?.identifier}-{issue.sequence_id}
</span>
{issue.name}
</>
)}
</Combobox.Option>
);
return (
<Combobox.Option
key={issue.id}
as="label"
htmlFor={`issue-${issue.id}`}
value={issue.id}
className={({ active }) =>
classNames(
"flex w-full cursor-pointer select-none items-center gap-2 rounded-md px-3 py-2",
active ? "bg-gray-900 bg-opacity-5 text-gray-900" : ""
)
}
>
{({ selected }) => (
<>
<input type="checkbox" checked={selected} readOnly />
<span
className="block h-1.5 w-1.5 flex-shrink-0 rounded-full"
style={{
backgroundColor: issue.state_detail.color,
}}
/>
<span className="flex-shrink-0 text-xs text-gray-500">
{projectDetails?.identifier}-{issue.sequence_id}
</span>
{issue.name}
</>
)}
</Combobox.Option>
);
})}
</ul>
</li>
) : (
<div className="flex flex-col items-center justify-center gap-4 px-3 py-8 text-center">
<LayerDiagonalIcon height="56" width="56" />
<h3 className="text-gray-500">
No issues found. Create a new issue with{" "}
<pre className="inline rounded bg-gray-100 px-2 py-1">
Ctrl/Command + I
</pre>
.
</h3>
</div>
)}
</Combobox.Options>
@ -186,19 +211,21 @@ const ExistingIssuesListModal: React.FC<Props> = ({
</Combobox>
)}
/>
<div className="flex justify-end items-center gap-2 p-3">
<Button type="button" theme="danger" size="sm" onClick={handleClose}>
Cancel
</Button>
<Button
type="button"
size="sm"
onClick={handleSubmit(onSubmit)}
disabled={isSubmitting}
>
{isSubmitting ? "Adding..." : `Add to ${type}`}
</Button>
</div>
{filteredIssues.length > 0 && (
<div className="flex items-center justify-end gap-2 p-3">
<Button type="button" theme="secondary" size="sm" onClick={handleClose}>
Cancel
</Button>
<Button
type="button"
size="sm"
onClick={handleSubmit(onSubmit)}
disabled={isSubmitting}
>
{isSubmitting ? "Adding..." : `Add to ${type}`}
</Button>
</div>
)}
</form>
</Dialog.Panel>
</Transition.Child>

View File

@ -0,0 +1,163 @@
import React, { useCallback, useEffect, useState } from "react";
import NextImage from "next/image";
import { useRouter } from "next/router";
import { useDropzone } from "react-dropzone";
import { Transition, Dialog } from "@headlessui/react";
// services
import fileServices from "lib/services/file.service";
// icon
import { UserCircleIcon } from "ui/icons";
// ui
import { Button } from "ui";
type TImageUploadModalProps = {
value?: string | null;
onClose: () => void;
isOpen: boolean;
onSuccess: (url: string) => void;
};
export const ImageUploadModal: React.FC<TImageUploadModalProps> = (props) => {
const { value, onSuccess, isOpen, onClose } = props;
const [image, setImage] = useState<File | null>(null);
const [isImageUploading, setIsImageUploading] = useState(false);
const router = useRouter();
const { workspaceSlug } = router.query;
const onDrop = useCallback((acceptedFiles: File[]) => {
setImage(acceptedFiles[0]);
}, []);
const {
getRootProps,
getInputProps,
isDragActive,
open: openFileDialog,
} = useDropzone({
onDrop,
});
const handleSubmit = async () => {
setIsImageUploading(true);
if (image === null || !workspaceSlug) return;
const formData = new FormData();
formData.append("asset", image);
formData.append("attributes", JSON.stringify({}));
fileServices
.uploadFile(workspaceSlug as string, formData)
.then((res) => {
const imageUrl = res.asset;
onSuccess(imageUrl);
setIsImageUploading(false);
})
.catch((err) => {
console.error(err);
});
};
const handleClose = () => {
onClose();
};
return (
<Transition.Root show={isOpen} as={React.Fragment}>
<Dialog as="div" className="relative z-10" onClose={handleClose}>
<Transition.Child
as={React.Fragment}
enter="ease-out duration-300"
enterFrom="opacity-0"
enterTo="opacity-100"
leave="ease-in duration-200"
leaveFrom="opacity-100"
leaveTo="opacity-0"
>
<div className="fixed inset-0 bg-gray-500 bg-opacity-75 transition-opacity" />
</Transition.Child>
<div className="fixed inset-0 z-10 overflow-y-auto">
<div className="flex min-h-full items-center justify-center p-4 text-center sm:p-0">
<Transition.Child
as={React.Fragment}
enter="ease-out duration-300"
enterFrom="opacity-0 translate-y-4 sm:translate-y-0 sm:scale-95"
enterTo="opacity-100 translate-y-0 sm:scale-100"
leave="ease-in duration-200"
leaveFrom="opacity-100 translate-y-0 sm:scale-100"
leaveTo="opacity-0 translate-y-4 sm:translate-y-0 sm:scale-95"
>
<Dialog.Panel className="relative transform overflow-hidden rounded-lg bg-white px-5 py-8 text-left shadow-xl transition-all sm:w-full sm:max-w-xl sm:p-6">
<div className="space-y-5">
<Dialog.Title as="h3" className="text-lg font-medium leading-6 text-gray-900">
Upload Image
</Dialog.Title>
<div className="space-y-3">
<div className="flex items-center gap-3">
<div
{...getRootProps()}
className={`relative block w-full rounded-lg p-12 text-center focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:ring-offset-2 ${
(image === null && isDragActive) || !value
? "border-2 border-dashed border-gray-300 hover:border-gray-400"
: ""
}`}
>
{value && value !== "" ? (
<>
<button
type="button"
onClick={openFileDialog}
className="absolute top-0 right-0 z-40 translate-x-1/2 -translate-y-1/2 rounded bg-gray-100 px-2 py-0.5 text-xs font-medium text-gray-600"
>
Edit
</button>
<NextImage
layout="fill"
objectFit="cover"
src={image ? URL.createObjectURL(image) : value}
alt="image"
/>
</>
) : (
<>
<UserCircleIcon className="mx-auto h-16 w-16 text-gray-400" />
<span className="mt-2 block text-sm font-medium text-gray-900">
{isDragActive
? "Drop image here to upload"
: "Drag & drop image here"}
</span>
</>
)}
<input {...getInputProps()} type="text" />
</div>
</div>
</div>
</div>
<div className="mt-5 sm:mt-6 sm:grid sm:grid-flow-row-dense sm:grid-cols-2 sm:gap-3">
<Button theme="secondary" onClick={handleClose}>
Cancel
</Button>
<Button
type="submit"
onClick={handleSubmit}
disabled={isImageUploading || image === null}
>
{isImageUploading ? "Uploading..." : "Upload & Save"}
</Button>
</div>
</Dialog.Panel>
</Transition.Child>
</div>
</div>
</Dialog>
</Transition.Root>
);
};

View File

@ -0,0 +1,175 @@
// next
import Link from "next/link";
import { useRouter } from "next/router";
import useSWR from "swr";
// services
import issuesService from "lib/services/issues.service";
// ui
import { CustomMenu } from "ui";
// icons
import { CalendarDaysIcon } from "@heroicons/react/24/outline";
// types
import { IIssue, IssueResponse, Properties } from "types";
// fetch-keys
import { PROJECT_ISSUES_LIST } from "constants/fetch-keys";
// common
import {
addSpaceIfCamelCase,
findHowManyDaysLeft,
renderShortNumericDateFormat,
} from "constants/common";
type Props = {
type?: string;
issue: IIssue;
properties: Properties;
editIssue: () => void;
handleDeleteIssue: () => void;
removeIssue: () => void;
};
const SingleListIssue: React.FC<Props> = ({
type,
issue,
properties,
editIssue,
handleDeleteIssue,
removeIssue,
}) => {
const router = useRouter();
let { workspaceSlug, projectId } = router.query;
const { data: issues } = useSWR<IssueResponse>(
workspaceSlug && projectId
? PROJECT_ISSUES_LIST(workspaceSlug as string, projectId as string)
: null,
workspaceSlug && projectId
? () => issuesService.getIssues(workspaceSlug as string, projectId as string)
: null
);
const totalChildren = issues?.results.filter((i) => i.parent === issue.id).length;
return (
<>
<div key={issue.id} className="flex items-center justify-between gap-2 px-4 py-3 text-sm">
<div className="flex items-center gap-2">
<span
className={`block h-1.5 w-1.5 flex-shrink-0 rounded-full`}
style={{
backgroundColor: issue.state_detail.color,
}}
/>
<Link href={`/${workspaceSlug}/projects/${issue?.project_detail?.id}/issues/${issue.id}`}>
<a className="group relative flex items-center gap-2">
{properties.key && (
<span className="flex-shrink-0 text-xs text-gray-500">
{issue.project_detail?.identifier}-{issue.sequence_id}
</span>
)}
<span>{issue.name}</span>
</a>
</Link>
</div>
<div className="flex flex-shrink-0 flex-wrap items-center gap-x-1 gap-y-2 text-xs">
{properties.priority && (
<div
className={`group relative flex flex-shrink-0 cursor-pointer items-center gap-1 rounded px-2 py-1 text-xs capitalize shadow-sm focus:border-indigo-500 focus:outline-none focus:ring-1 focus:ring-indigo-500 ${
issue.priority === "urgent"
? "bg-red-100 text-red-600"
: issue.priority === "high"
? "bg-orange-100 text-orange-500"
: issue.priority === "medium"
? "bg-yellow-100 text-yellow-500"
: issue.priority === "low"
? "bg-green-100 text-green-500"
: "bg-gray-100"
}`}
>
{/* {getPriorityIcon(issue.priority ?? "")} */}
{issue.priority ?? "None"}
<div className="absolute bottom-full right-0 z-10 mb-2 hidden whitespace-nowrap rounded-md bg-white p-2 shadow-md group-hover:block">
<h5 className="mb-1 font-medium text-gray-900">Priority</h5>
<div
className={`capitalize ${
issue.priority === "urgent"
? "text-red-600"
: issue.priority === "high"
? "text-orange-500"
: issue.priority === "medium"
? "text-yellow-500"
: issue.priority === "low"
? "text-green-500"
: ""
}`}
>
{issue.priority ?? "None"}
</div>
</div>
</div>
)}
{properties.state && (
<div className="group relative flex flex-shrink-0 cursor-pointer items-center gap-1 rounded border px-2 py-1 text-xs shadow-sm duration-300 hover:bg-gray-100 focus:border-indigo-500 focus:outline-none focus:ring-1 focus:ring-indigo-500">
<span
className="h-1.5 w-1.5 flex-shrink-0 rounded-full"
style={{
backgroundColor: issue?.state_detail?.color,
}}
></span>
{addSpaceIfCamelCase(issue?.state_detail.name)}
<div className="absolute bottom-full right-0 z-10 mb-2 hidden whitespace-nowrap rounded-md bg-white p-2 shadow-md group-hover:block">
<h5 className="mb-1 font-medium">State</h5>
<div>{issue?.state_detail.name}</div>
</div>
</div>
)}
{properties.due_date && (
<div
className={`group group relative flex flex-shrink-0 cursor-pointer items-center gap-1 rounded border px-2 py-1 text-xs shadow-sm duration-300 hover:bg-gray-100 focus:border-indigo-500 focus:outline-none focus:ring-1 focus:ring-indigo-500 ${
issue.target_date === null
? ""
: issue.target_date < new Date().toISOString()
? "text-red-600"
: findHowManyDaysLeft(issue.target_date) <= 3 && "text-orange-400"
}`}
>
<CalendarDaysIcon className="h-4 w-4" />
{issue.target_date ? renderShortNumericDateFormat(issue.target_date) : "N/A"}
<div className="absolute bottom-full right-0 z-10 mb-2 hidden whitespace-nowrap rounded-md bg-white p-2 shadow-md group-hover:block">
<h5 className="mb-1 font-medium text-gray-900">Due date</h5>
<div>{renderShortNumericDateFormat(issue.target_date ?? "")}</div>
<div>
{issue.target_date &&
(issue.target_date < new Date().toISOString()
? `Due date has passed by ${findHowManyDaysLeft(issue.target_date)} days`
: findHowManyDaysLeft(issue.target_date) <= 3
? `Due date is in ${findHowManyDaysLeft(issue.target_date)} days`
: "Due date")}
</div>
</div>
</div>
)}
{properties.sub_issue_count && projectId && (
<div className="flex flex-shrink-0 items-center gap-1 rounded border px-2 py-1 text-xs shadow-sm duration-300 hover:bg-gray-100 focus:border-indigo-500 focus:outline-none focus:ring-1 focus:ring-indigo-500">
{totalChildren} {totalChildren === 1 ? "sub-issue" : "sub-issues"}
</div>
)}
{type && (
<CustomMenu width="auto" ellipsis>
<CustomMenu.MenuItem onClick={() => editIssue()}>Edit</CustomMenu.MenuItem>
<CustomMenu.MenuItem onClick={() => removeIssue()}>
<>Remove from {type}</>
</CustomMenu.MenuItem>
<CustomMenu.MenuItem onClick={() => handleDeleteIssue()}>
Delete permanently
</CustomMenu.MenuItem>
</CustomMenu>
)}
</div>
</div>
</>
);
};
export default SingleListIssue;

View File

@ -0,0 +1 @@
export * from "./not-authorized-view";

View File

@ -0,0 +1,59 @@
import React from "react";
// next
import Link from "next/link";
import { useRouter } from "next/router";
// layouts
import DefaultLayout from "layouts/default-layout";
// hooks
import useUser from "lib/hooks/useUser";
// icons
import { LockIcon } from "ui/icons";
type TNotAuthorizedViewProps = {
actionButton?: React.ReactNode;
};
export const NotAuthorizedView: React.FC<TNotAuthorizedViewProps> = (props) => {
const { actionButton } = props;
const { user } = useUser();
const { asPath: currentPath } = useRouter();
return (
<DefaultLayout
meta={{
title: "Plane - Not Authorized",
description: "You are not authorized to view this page",
}}
>
<div className="flex h-full w-full flex-col items-center justify-center gap-y-5 text-center">
<LockIcon className="h-16 w-16 text-gray-400" />
<h1 className="text-xl font-medium text-gray-900">
Oops! You are not authorized to view this page
</h1>
<div className="w-full md:w-1/3">
{user ? (
<p className="text-base font-light">
You have signed in as <span className="font-medium">{user.email}</span>.{" "}
<Link href={`/signin?next=${currentPath}`}>
<a className="font-medium">Sign in</a>
</Link>{" "}
with different account that has access to this page.
</p>
) : (
<p className="text-base font-light">
You need to{" "}
<Link href={`/signin?next=${currentPath}`}>
<a className="font-medium">Sign in</a>
</Link>{" "}
with an account that has access to this page.
</p>
)}
</div>
{actionButton}
</div>
</DefaultLayout>
);
};

View File

@ -0,0 +1,179 @@
import React from "react";
import { useRouter } from "next/router";
// hooks
import useIssuesProperties from "lib/hooks/useIssuesProperties";
// headless ui
import { Popover, Transition } from "@headlessui/react";
// ui
import { CustomMenu } from "ui";
// icons
import { ChevronDownIcon } from "@heroicons/react/24/outline";
// types
import { IIssue, NestedKeyOf, Properties } from "types";
// common
import { classNames, replaceUnderscoreIfSnakeCase } from "constants/common";
// constants
import { filterIssueOptions, groupByOptions, orderByOptions } from "constants/";
type Props = {
groupByProperty: NestedKeyOf<IIssue> | null;
setGroupByProperty: (property: NestedKeyOf<IIssue> | null) => void;
orderBy: NestedKeyOf<IIssue> | null;
setOrderBy: (property: NestedKeyOf<IIssue> | null) => void;
filterIssue: "activeIssue" | "backlogIssue" | null;
setFilterIssue: (property: "activeIssue" | "backlogIssue" | null) => void;
resetFilterToDefault: () => void;
setNewFilterDefaultView: () => void;
};
const View: React.FC<Props> = ({
groupByProperty,
setGroupByProperty,
orderBy,
setOrderBy,
filterIssue,
setFilterIssue,
resetFilterToDefault,
setNewFilterDefaultView,
}) => {
const router = useRouter();
const { workspaceSlug, projectId } = router.query;
const [properties, setProperties] = useIssuesProperties(
workspaceSlug as string,
projectId as string
);
return (
<>
<Popover className="relative">
{({ open }) => (
<>
<Popover.Button
className={classNames(
open ? "bg-gray-100 text-gray-900" : "text-gray-500",
"group flex items-center gap-2 rounded-md border bg-transparent p-2 text-xs font-medium hover:bg-gray-100 hover:text-gray-900 focus:outline-none"
)}
>
<span>View</span>
<ChevronDownIcon className="h-4 w-4" aria-hidden="true" />
</Popover.Button>
<Transition
as={React.Fragment}
enter="transition ease-out duration-200"
enterFrom="opacity-0 translate-y-1"
enterTo="opacity-100 translate-y-0"
leave="transition ease-in duration-150"
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 bg-white p-3 shadow-lg">
<div className="relative divide-y-2">
<div className="space-y-4 pb-3">
<div className="flex items-center justify-between">
<h4 className="text-sm text-gray-600">Group by</h4>
<CustomMenu
label={
groupByOptions.find((option) => option.key === groupByProperty)?.name ??
"Select"
}
width="lg"
>
{groupByOptions.map((option) => (
<CustomMenu.MenuItem
key={option.key}
onClick={() => setGroupByProperty(option.key)}
>
{option.name}
</CustomMenu.MenuItem>
))}
</CustomMenu>
</div>
<div className="flex items-center justify-between">
<h4 className="text-sm text-gray-600">Order by</h4>
<CustomMenu
label={
orderByOptions.find((option) => option.key === orderBy)?.name ?? "Select"
}
width="lg"
>
{orderByOptions.map((option) =>
groupByProperty === "priority" && option.key === "priority" ? null : (
<CustomMenu.MenuItem
key={option.key}
onClick={() => setOrderBy(option.key)}
>
{option.name}
</CustomMenu.MenuItem>
)
)}
</CustomMenu>
</div>
<div className="flex items-center justify-between">
<h4 className="text-sm text-gray-600">Issue type</h4>
<CustomMenu
label={
filterIssueOptions.find((option) => option.key === filterIssue)?.name ??
"Select"
}
width="lg"
>
{filterIssueOptions.map((option) => (
<CustomMenu.MenuItem
key={option.key}
onClick={() => setFilterIssue(option.key)}
>
{option.name}
</CustomMenu.MenuItem>
))}
</CustomMenu>
</div>
<div className="relative flex justify-end gap-x-3">
<button
type="button"
className="text-xs"
onClick={() => resetFilterToDefault()}
>
Reset to default
</button>
<button
type="button"
className="text-xs font-medium text-theme"
onClick={() => setNewFilterDefaultView()}
>
Set as default
</button>
</div>
</div>
<div className="space-y-2 py-3">
<h4 className="text-sm text-gray-600">Display Properties</h4>
<div className="flex flex-wrap items-center gap-2">
{Object.keys(properties).map((key) => (
<button
key={key}
type="button"
className={`rounded border px-2 py-1 text-xs capitalize ${
properties[key as keyof Properties]
? "border-theme bg-theme text-white"
: "border-gray-300"
}`}
onClick={() => setProperties(key as keyof Properties)}
>
{replaceUnderscoreIfSnakeCase(key)}
</button>
))}
</div>
</div>
</div>
</Popover.Panel>
</Transition>
</>
)}
</Popover>
</>
);
};
export default View;

View File

@ -1,31 +0,0 @@
import { HeadingNode, QuoteNode } from "@lexical/rich-text";
import { TableCellNode, TableNode, TableRowNode } from "@lexical/table";
import { ListItemNode, ListNode } from "@lexical/list";
import { CodeHighlightNode, CodeNode } from "@lexical/code";
import { AutoLinkNode, LinkNode } from "@lexical/link";
// theme
import { defaultTheme } from "./theme";
export const initialConfig = {
namespace: "LexicalEditor",
// The editor theme
theme: defaultTheme,
// Handling of errors during update
onError(error: any) {
console.error(error);
},
// Any custom nodes go here
nodes: [
HeadingNode,
ListNode,
ListItemNode,
QuoteNode,
CodeNode,
CodeHighlightNode,
TableNode,
TableCellNode,
TableRowNode,
AutoLinkNode,
LinkNode,
],
};

View File

@ -1,89 +0,0 @@
import {
EditorState,
$getRoot,
$getSelection,
SerializedEditorState,
LexicalEditor,
} from "lexical";
import { LexicalComposer } from "@lexical/react/LexicalComposer";
import { ContentEditable } from "@lexical/react/LexicalContentEditable";
import { RichTextPlugin } from "@lexical/react/LexicalRichTextPlugin";
import { LinkPlugin } from "@lexical/react/LexicalLinkPlugin";
import { ListPlugin } from "@lexical/react/LexicalListPlugin";
import { HistoryPlugin } from "@lexical/react/LexicalHistoryPlugin";
import { MarkdownShortcutPlugin } from "@lexical/react/LexicalMarkdownShortcutPlugin";
import { TRANSFORMERS, CHECK_LIST } from "@lexical/markdown";
import { OnChangePlugin } from "@lexical/react/LexicalOnChangePlugin";
import { $generateHtmlFromNodes } from "@lexical/html";
import { CheckListPlugin } from "@lexical/react/LexicalCheckListPlugin";
// custom plugins
import { CodeHighlightPlugin } from "./plugins/code-highlight";
import { LexicalToolbar } from "./toolbar";
// config
import { initialConfig } from "./config";
// helpers
import { getValidatedValue } from "./helpers/editor";
import LexicalErrorBoundary from "@lexical/react/LexicalErrorBoundary";
export interface RichTextEditorProps {
onChange: (state: string) => void;
id: string;
value: string;
placeholder?: string;
}
const RichTextEditor: React.FC<RichTextEditorProps> = ({
onChange,
id,
value,
placeholder = "Enter some text...",
}) => {
const handleChange = (editorState: EditorState) => {
editorState.read(() => {
onChange(JSON.stringify(editorState.toJSON()));
});
};
// function handleChange(state: EditorState, editor: LexicalEditor) {
// state.read(() => {
// onChange(state.toJSON());
// });
// }
return (
<LexicalComposer
initialConfig={{
...initialConfig,
namespace: id || "Lexical Editor",
editorState: getValidatedValue(value),
}}
>
<div className="border border-[#e2e2e2] rounded-md">
<LexicalToolbar />
<div className="relative">
<RichTextPlugin
contentEditable={
<ContentEditable className='className="h-[450px] outline-none py-[15px] px-2.5 resize-none overflow-hidden text-ellipsis' />
}
ErrorBoundary={LexicalErrorBoundary}
placeholder={
<div className="absolute top-4 left-3 pointer-events-none select-none text-gray-400">
{placeholder}
</div>
}
/>
<OnChangePlugin onChange={handleChange} />
<HistoryPlugin />
<CodeHighlightPlugin />
<ListPlugin />
<LinkPlugin />
<CheckListPlugin />
<MarkdownShortcutPlugin transformers={TRANSFORMERS} />
</div>
</div>
</LexicalComposer>
);
};
export default RichTextEditor;

View File

@ -1,33 +0,0 @@
export const positionEditorElement = (editor: any, rect: any) => {
if (window) {
if (rect === null) {
editor.style.opacity = "0";
editor.style.top = "-1000px";
editor.style.left = "-1000px";
} else {
editor.style.opacity = "1";
editor.style.top = `${rect.top + rect.height + window.pageYOffset + 10}px`;
editor.style.left = `${
rect.left + window.pageXOffset - editor.offsetWidth / 2 + rect.width / 2
}px`;
}
}
};
export const getValidatedValue = (value: string) => {
const defaultValue =
'{"root":{"children":[{"children":[],"direction":null,"format":"","indent":0,"type":"paragraph","version":1}],"direction":null,"format":"","indent":0,"type":"root","version":1}}';
console.log("Value: ", value);
if (value) {
try {
const data = JSON.parse(value);
return JSON.stringify(data);
} catch (e) {
return defaultValue;
}
}
return defaultValue;
};

View File

@ -1,17 +0,0 @@
import { $isAtNodeEnd } from "@lexical/selection";
export const getSelectedNode = (selection: any) => {
const anchor = selection.anchor;
const focus = selection.focus;
const anchorNode = selection.anchor.getNode();
const focusNode = selection.focus.getNode();
if (anchorNode === focusNode) {
return anchorNode;
}
const isBackward = selection.isBackward();
if (isBackward) {
return $isAtNodeEnd(focus) ? anchorNode : focusNode;
} else {
return $isAtNodeEnd(anchor) ? focusNode : anchorNode;
}
};

View File

@ -1,11 +0,0 @@
import { useEffect } from "react";
import { registerCodeHighlighting } from "@lexical/code";
import { useLexicalComposerContext } from "@lexical/react/LexicalComposerContext";
export const CodeHighlightPlugin = () => {
const [editor] = useLexicalComposerContext();
useEffect(() => {
return registerCodeHighlighting(editor);
}, [editor]);
return null;
};

View File

@ -1,21 +0,0 @@
import { useEffect, useState } from "react";
import { useLexicalComposerContext } from "@lexical/react/LexicalComposerContext";
import { getValidatedValue } from "../helpers/editor";
const ReadOnlyPlugin = ({ value }: { value: string }) => {
const [editor] = useLexicalComposerContext();
useEffect(() => {
if (editor && value) {
const initialEditorState = editor?.parseEditorState(
getValidatedValue(value) || ""
);
editor.setEditorState(initialEditorState);
}
}, [editor, value]);
return <></>;
};
export default ReadOnlyPlugin;

View File

@ -1,67 +0,0 @@
export const defaultTheme = {
ltr: "ltr",
rtl: "rtl",
placeholder: "editor-placeholder",
paragraph: "mb-1",
quote: "editor-quote",
heading: {
h1: "text-3xl font-bold",
h2: "text-2xl font-bold",
h3: "text-xl font-bold",
h4: "text-lg font-bold",
h5: "text-base font-bold",
},
list: {
nested: {
listitem: "list-item",
},
ol: "list-decimal pl-4",
ul: "list-disc pl-4",
listitem: "list-item",
},
image: "editor-image",
link: "editor-link",
text: {
bold: "font-bold",
italic: "italic",
overflowed: "editor-text-overflowed",
hashtag: "editor-text-hashtag",
underline: "underline",
strikethrough: "line-through",
underlineStrikethrough: "editor-text-underlineStrikethrough",
code: "editor-text-code",
},
code: "editor-code",
codeHighlight: {
atrule: "editor-tokenAttr",
attr: "editor-tokenAttr",
boolean: "editor-tokenProperty",
builtin: "editor-tokenSelector",
cdata: "editor-tokenComment",
char: "editor-tokenSelector",
class: "editor-tokenFunction",
"class-name": "editor-tokenFunction",
comment: "editor-tokenComment",
constant: "editor-tokenProperty",
deleted: "editor-tokenProperty",
doctype: "editor-tokenComment",
entity: "editor-tokenOperator",
function: "editor-tokenFunction",
important: "editor-tokenVariable",
inserted: "editor-tokenSelector",
keyword: "editor-tokenAttr",
namespace: "editor-tokenVariable",
number: "editor-tokenProperty",
operator: "editor-tokenOperator",
prolog: "editor-tokenComment",
property: "editor-tokenProperty",
punctuation: "editor-tokenPunctuation",
regex: "editor-tokenVariable",
selector: "editor-tokenSelector",
string: "editor-tokenSelector",
symbol: "editor-tokenProperty",
tag: "editor-tokenProperty",
url: "editor-tokenOperator",
variable: "editor-tokenVariable",
},
};

View File

@ -1,317 +0,0 @@
import { FC, Fragment, useState, useEffect, useRef } from "react";
import { createPortal } from "react-dom";
import {
CAN_REDO_COMMAND,
CAN_UNDO_COMMAND,
REDO_COMMAND,
UNDO_COMMAND,
SELECTION_CHANGE_COMMAND,
FORMAT_TEXT_COMMAND,
FORMAT_ELEMENT_COMMAND,
$getSelection,
$isRangeSelection,
$createParagraphNode,
$getNodeByKey,
} from "lexical";
import {
INSERT_ORDERED_LIST_COMMAND,
INSERT_UNORDERED_LIST_COMMAND,
INSERT_CHECK_LIST_COMMAND,
REMOVE_LIST_COMMAND,
$isListNode,
ListNode,
} from "@lexical/list";
import { $isParentElementRTL, $isAtNodeEnd, $wrapNodes } from "@lexical/selection";
import { $createHeadingNode, $createQuoteNode, $isHeadingNode } from "@lexical/rich-text";
import {
$createCodeNode,
$isCodeNode,
getDefaultCodeLanguage,
getCodeLanguages,
} from "@lexical/code";
const BLOCK_DATA = [
{ type: "paragraph", name: "Normal" },
{ type: "h1", name: "Large Heading" },
{ type: "h2", name: "Small Heading" },
{ type: "h3", name: "Heading" },
{ type: "h4", name: "Heading" },
{ type: "h5", name: "Heading" },
{ type: "Quote", name: "quote" },
{ type: "ol", name: "Numbered List" },
{ type: "ul", name: "Bulleted List" },
];
const supportedBlockTypes = new Set(["paragraph", "quote", "code", "h1", "h2", "ul", "ol"]);
const blockTypeToBlockName: any = {
code: "Code Block",
h1: "Large Heading",
h2: "Small Heading",
h3: "Heading",
h4: "Heading",
h5: "Heading",
ol: "Numbered List",
paragraph: "Normal",
quote: "Quote",
ul: "Bulleted List",
};
export interface BlockTypeSelectProps {
editor: any;
toolbarRef: any;
blockType: string;
}
export const BlockTypeSelect: FC<BlockTypeSelectProps> = (props) => {
const { editor, toolbarRef, blockType } = props;
// refs
const dropDownRef = useRef<any>(null);
// states
const [showBlockOptionsDropDown, setShowBlockOptionsDropDown] = useState(false);
useEffect(() => {
const toolbar = toolbarRef.current;
const dropDown = dropDownRef.current;
if (toolbar !== null && dropDown !== null) {
const { top, left } = toolbar.getBoundingClientRect();
dropDown.style.top = `${top + 40}px`;
dropDown.style.left = `${left}px`;
}
}, [dropDownRef, toolbarRef]);
useEffect(() => {
const dropDown = dropDownRef.current;
const toolbar = toolbarRef.current;
if (dropDown !== null && toolbar !== null) {
const handle = (event: any) => {
const target = event.target;
if (!dropDown.contains(target) && !toolbar.contains(target)) {
setShowBlockOptionsDropDown(false);
}
};
document.addEventListener("click", handle);
return () => {
document.removeEventListener("click", handle);
};
}
}, [dropDownRef, setShowBlockOptionsDropDown, toolbarRef]);
const formatParagraph = () => {
if (blockType !== "paragraph") {
editor.update(() => {
const selection = $getSelection();
if ($isRangeSelection(selection)) {
$wrapNodes(selection, () => $createParagraphNode());
}
});
}
setShowBlockOptionsDropDown(false);
};
const formatLargeHeading = () => {
console.log("blockType ", blockType);
if (blockType !== "h1") {
editor.update(() => {
const selection = $getSelection();
if ($isRangeSelection(selection)) {
$wrapNodes(selection, () => $createHeadingNode("h1"));
}
});
}
setShowBlockOptionsDropDown(false);
};
const formatSmallHeading = () => {
if (blockType !== "h2") {
editor.update(() => {
const selection = $getSelection();
if ($isRangeSelection(selection)) {
$wrapNodes(selection, () => $createHeadingNode("h2"));
}
});
}
setShowBlockOptionsDropDown(false);
};
const formatBulletList = () => {
if (blockType !== "ul") {
editor.dispatchCommand(INSERT_UNORDERED_LIST_COMMAND);
} else {
editor.dispatchCommand(REMOVE_LIST_COMMAND);
}
setShowBlockOptionsDropDown(false);
};
const formatNumberedList = () => {
if (blockType !== "ol") {
editor.dispatchCommand(INSERT_ORDERED_LIST_COMMAND);
} else {
editor.dispatchCommand(REMOVE_LIST_COMMAND);
}
setShowBlockOptionsDropDown(false);
};
const formatQuote = () => {
if (blockType !== "quote") {
editor.update(() => {
const selection = $getSelection();
if ($isRangeSelection(selection)) {
$wrapNodes(selection, () => $createQuoteNode());
}
});
}
setShowBlockOptionsDropDown(false);
};
const formatCode = () => {
if (blockType !== "code") {
editor.update(() => {
const selection = $getSelection();
if ($isRangeSelection(selection)) {
$wrapNodes(selection, () => $createCodeNode());
}
});
}
setShowBlockOptionsDropDown(false);
};
return (
<div className="relative">
<button
type="button"
className="p-2 mr-2 text-sm flex items-center"
onClick={() => setShowBlockOptionsDropDown(!showBlockOptionsDropDown)}
aria-label="Formatting Options"
>
<span className="mr-2">{blockTypeToBlockName[blockType]}</span>
<svg
xmlns="http://www.w3.org/2000/svg"
width="16"
height="16"
fill="currentColor"
className="bi bi-chevron-down"
viewBox="0 0 16 16"
>
<path
fillRule="evenodd"
d="M1.646 4.646a.5.5 0 01.708 0L8 10.293l5.646-5.647a.5.5 0 01.708.708l-6 6a.5.5 0 01-.708 0l-6-6a.5.5 0 010-.708z"
></path>
</svg>
</button>
{showBlockOptionsDropDown && (
<ul
className="absolute mt-1 w-full min-w-[160px] overflow-auto rounded-md bg-white z-10 p-1 py-1 text-base shadow-lg ring-1 ring-black ring-opacity-5 focus:outline-none sm:text-sm"
ref={dropDownRef}
>
<li className="p-1 cursor-pointer" onClick={formatParagraph}>
<span className="icon paragraph" />
<span className="text">Normal</span>
{blockType === "paragraph" && <span className="active" />}
</li>
<li className="p-1 cursor-pointer" onClick={formatLargeHeading}>
<span className="icon large-heading" />
<span className="text">Large Heading</span>
{blockType === "h1" && <span className="active" />}
</li>
<li className="p-1 cursor-pointer" onClick={formatSmallHeading}>
<span className="icon small-heading" />
<span className="text">Small Heading</span>
{blockType === "h2" && <span className="active" />}
</li>
<li className="p-1 cursor-pointer" onClick={formatBulletList}>
<span className="icon bullet-list" />
<span className="text">Bullet List</span>
{blockType === "ul" && <span className="active" />}
</li>
<li className="p-1 cursor-pointer" onClick={formatNumberedList}>
<span className="icon numbered-list" />
<span className="text">Numbered List</span>
{blockType === "ol" && <span className="active" />}
</li>
<li className="p-1 cursor-pointer" onClick={formatQuote}>
<span className="icon quote" />
<span className="text">Quote</span>
{blockType === "quote" && <span className="active" />}
</li>
{/* <button className="item" onClick={formatCode}>
<span className="icon code" />
<span className="text">Code Block</span>
{blockType === 'code' && <span className="active" />}
</button> */}
</ul>
)}
</div>
);
};
// export const BlockTypeSelect: FC<any> = () => {
// const [selected, setSelected] = useState(BLOCK_DATA[0]);
// return (
// <div className="inline-flex pr-1">
// <Listbox value={selected} onChange={setSelected}>
// <div className="relative">
// <Listbox.Button className="relative w-full min-w-[160px] cursor-default rounded border border-[#e2e2e2] bg-white py-1 pl-3 pr-10 text-left outline-none focus-visible:border-indigo-500 focus-visible:ring-2 focus-visible:ring-white focus-visible:ring-opacity-75 focus-visible:ring-offset-2 focus-visible:ring-offset-orange-300 text-xs">
// <span className="block truncate">{selected.name}</span>
// <span className="pointer-events-none absolute inset-y-0 right-0 flex items-center pr-2">
// <svg
// xmlns="http://www.w3.org/2000/svg"
// width="16"
// height="16"
// fill="currentColor"
// className="bi bi-chevron-down"
// viewBox="0 0 16 16"
// >
// <path
// fillRule="evenodd"
// d="M1.646 4.646a.5.5 0 01.708 0L8 10.293l5.646-5.647a.5.5 0 01.708.708l-6 6a.5.5 0 01-.708 0l-6-6a.5.5 0 010-.708z"
// ></path>
// </svg>
// </span>
// </Listbox.Button>
// <Transition
// as={Fragment}
// leave="transition ease-in duration-100"
// leaveFrom="opacity-100"
// leaveTo="opacity-0"
// >
// <Listbox.Options className="absolute mt-1 max-h-60 w-full min-w-[160px] overflow-auto rounded-md bg-white py-1 text-base shadow-lg ring-1 ring-black ring-opacity-5 focus:outline-none sm:text-sm">
// {BLOCK_DATA.map((blockType, index) => (
// <Listbox.Option
// key={index}
// className={({ active }) =>
// `relative cursor-default select-none py-2 px-2 ${
// active ? 'bg-amber-100 text-amber-900' : 'text-gray-900'
// }`
// }
// value={blockType}
// >
// {({ selected }) => (
// <>
// <span
// className={`block truncate ${selected ? 'font-medium' : 'font-normal'}`}
// >
// {blockType.name}
// </span>
// </>
// )}
// </Listbox.Option>
// ))}
// </Listbox.Options>
// </Transition>
// </div>
// </Listbox>
// </div>
// );
// };

View File

@ -1,156 +0,0 @@
import { useRef, useState, useCallback, useEffect } from 'react';
import { SELECTION_CHANGE_COMMAND, $getSelection, $isRangeSelection } from 'lexical';
import { $isLinkNode, TOGGLE_LINK_COMMAND } from '@lexical/link';
import { mergeRegister } from '@lexical/utils';
// helper functions
import { positionEditorElement } from '../helpers/editor';
import { getSelectedNode } from '../helpers/node';
const LowPriority = 1;
export interface FloatingLinkEditorProps {
editor: any;
}
export const FloatingLinkEditor = ({ editor }: FloatingLinkEditorProps) => {
// refs
const editorRef = useRef<any>(null);
const inputRef = useRef<any>(null);
const mouseDownRef = useRef(false);
// states
const [linkUrl, setLinkUrl] = useState('');
const [isEditMode, setEditMode] = useState(false);
const [lastSelection, setLastSelection] = useState<any>(null);
const updateLinkEditor = useCallback(() => {
const selection = $getSelection();
if ($isRangeSelection(selection)) {
const node = getSelectedNode(selection);
const parent = node.getParent();
if ($isLinkNode(parent)) {
setLinkUrl(parent.getURL());
} else if ($isLinkNode(node)) {
setLinkUrl(node.getURL());
} else {
setLinkUrl('');
}
}
const editorElem = editorRef.current;
const nativeSelection = window?.getSelection();
const activeElement = document.activeElement;
if (editorElem === null) {
return;
}
const rootElement = editor.getRootElement();
if (
selection !== null &&
!nativeSelection?.isCollapsed &&
rootElement !== null &&
rootElement.contains(nativeSelection?.anchorNode)
) {
const domRange = nativeSelection?.getRangeAt(0);
let rect;
if (nativeSelection?.anchorNode === rootElement) {
let inner = rootElement;
while (inner.firstElementChild != null) {
inner = inner.firstElementChild;
}
rect = inner.getBoundingClientRect();
} else {
rect = domRange?.getBoundingClientRect();
}
if (!mouseDownRef.current) {
positionEditorElement(editorElem, rect);
}
setLastSelection(selection);
} else if (!activeElement || activeElement.className !== 'link-input') {
positionEditorElement(editorElem, null);
setLastSelection(null);
setEditMode(false);
setLinkUrl('');
}
return true;
}, [editor]);
useEffect(() => {
return mergeRegister(
editor.registerUpdateListener(({ editorState }: any) => {
editorState.read(() => {
updateLinkEditor();
});
}),
editor.registerCommand(
SELECTION_CHANGE_COMMAND,
() => {
updateLinkEditor();
return true;
},
LowPriority
)
);
}, [editor, updateLinkEditor]);
useEffect(() => {
editor.getEditorState().read(() => {
updateLinkEditor();
});
}, [editor, updateLinkEditor]);
useEffect(() => {
if (isEditMode && inputRef?.current) {
inputRef.current.focus();
}
}, [isEditMode]);
return (
<div ref={editorRef} className="link-editor">
{isEditMode ? (
<input
ref={inputRef}
className="link-input"
value={linkUrl}
onChange={(event) => {
setLinkUrl(event.target.value);
}}
onKeyDown={(event) => {
if (event.key === 'Enter') {
event.preventDefault();
if (lastSelection !== null) {
if (linkUrl !== '') {
editor.dispatchCommand(TOGGLE_LINK_COMMAND, linkUrl);
}
setEditMode(false);
}
} else if (event.key === 'Escape') {
event.preventDefault();
setEditMode(false);
}
}}
/>
) : (
<>
<div className="link-input">
<a href={linkUrl} target="_blank" rel="noopener noreferrer">
{linkUrl}
</a>
<div
className="link-edit"
role="button"
tabIndex={0}
onMouseDown={(event) => event.preventDefault()}
onClick={() => {
setEditMode(true);
}}
/>
</div>
</>
)}
</div>
);
};

View File

@ -1,420 +0,0 @@
import { useEffect, useState, useRef, useCallback } from "react";
import { createPortal } from "react-dom";
import {
CAN_REDO_COMMAND,
CAN_UNDO_COMMAND,
REDO_COMMAND,
UNDO_COMMAND,
SELECTION_CHANGE_COMMAND,
FORMAT_TEXT_COMMAND,
FORMAT_ELEMENT_COMMAND,
$getSelection,
$isRangeSelection,
$createParagraphNode,
$getNodeByKey,
RangeSelection,
NodeSelection,
GridSelection,
} from "lexical";
import {
INSERT_ORDERED_LIST_COMMAND,
INSERT_UNORDERED_LIST_COMMAND,
REMOVE_LIST_COMMAND,
$isListNode,
ListNode,
} from "@lexical/list";
import { $isLinkNode, TOGGLE_LINK_COMMAND } from "@lexical/link";
import { useLexicalComposerContext } from "@lexical/react/LexicalComposerContext";
import { $isParentElementRTL, $wrapNodes, $isAtNodeEnd } from "@lexical/selection";
import { $getNearestNodeOfType, mergeRegister } from "@lexical/utils";
import { $createHeadingNode, $createQuoteNode, $isHeadingNode } from "@lexical/rich-text";
// custom elements
import { FloatingLinkEditor } from "./floating-link-editor";
import { BlockTypeSelect } from "./block-type-select";
const LowPriority = 1;
function getSelectedNode(selection: any) {
const anchor = selection.anchor;
const focus = selection.focus;
const anchorNode = selection.anchor.getNode();
const focusNode = selection.focus.getNode();
if (anchorNode === focusNode) {
return anchorNode;
}
const isBackward = selection.isBackward();
if (isBackward) {
return $isAtNodeEnd(focus) ? anchorNode : focusNode;
} else {
return $isAtNodeEnd(anchor) ? focusNode : anchorNode;
}
}
export const LexicalToolbar = () => {
// editor
const [editor] = useLexicalComposerContext();
// ref
const toolbarRef = useRef(null);
// states
const [canUndo, setCanUndo] = useState(false);
const [canRedo, setCanRedo] = useState(false);
const [blockType, setBlockType] = useState("paragraph");
const [selectedElementKey, setSelectedElementKey] = useState<string | null>(null);
const [isRTL, setIsRTL] = useState(false);
const [isLink, setIsLink] = useState(false);
const [isBold, setIsBold] = useState(false);
const [isItalic, setIsItalic] = useState(false);
const [isUnderline, setIsUnderline] = useState(false);
const [isStrikethrough, setIsStrikethrough] = useState(false);
const [isCode, setIsCode] = useState(false);
const updateToolbar = useCallback(() => {
const selection = $getSelection();
if ($isRangeSelection(selection)) {
const anchorNode = selection.anchor.getNode();
const element =
anchorNode.getKey() === "root" ? anchorNode : anchorNode.getTopLevelElementOrThrow();
const elementKey = element.getKey();
const elementDOM = editor.getElementByKey(elementKey);
if (elementDOM !== null) {
setSelectedElementKey(elementKey);
if ($isListNode(element)) {
const parentList = $getNearestNodeOfType(anchorNode, ListNode);
const type = parentList ? parentList.getTag() : element.getTag();
setBlockType(type);
} else {
const type = $isHeadingNode(element) ? element.getTag() : element.getType();
setBlockType(type);
}
}
// Update text format
setIsBold(selection.hasFormat("bold"));
setIsItalic(selection.hasFormat("italic"));
setIsUnderline(selection.hasFormat("underline"));
setIsStrikethrough(selection.hasFormat("strikethrough"));
setIsRTL($isParentElementRTL(selection));
// Update links
const node = getSelectedNode(selection);
const parent = node.getParent();
if ($isLinkNode(parent) || $isLinkNode(node)) {
setIsLink(true);
} else {
setIsLink(false);
}
}
}, [editor]);
useEffect(() => {
return mergeRegister(
editor.registerUpdateListener(({ editorState }) => {
editorState.read(() => {
updateToolbar();
});
}),
editor.registerCommand(
SELECTION_CHANGE_COMMAND,
(_payload, newEditor) => {
updateToolbar();
return false;
},
LowPriority
),
editor.registerCommand(
CAN_UNDO_COMMAND,
(payload) => {
setCanUndo(payload);
return false;
},
LowPriority
),
editor.registerCommand(
CAN_REDO_COMMAND,
(payload) => {
setCanRedo(payload);
return false;
},
LowPriority
)
);
}, [editor, updateToolbar]);
const insertLink = useCallback(
(e: any) => {
e.preventDefault();
if (!isLink) {
editor.dispatchCommand(TOGGLE_LINK_COMMAND, "https://");
} else {
editor.dispatchCommand(TOGGLE_LINK_COMMAND, null);
}
},
[editor, isLink]
);
return (
<div className="flex items-center mb-1 p-1 w-full flex-wrap border-b " ref={toolbarRef}>
<button
type="button"
disabled={!canUndo}
onClick={(e) => {
e.preventDefault();
editor.dispatchCommand(UNDO_COMMAND, undefined);
}}
className="p-2 mr-2"
aria-label="Undo"
>
<svg
xmlns="http://www.w3.org/2000/svg"
width="16"
height="16"
fill="currentColor"
className="bi bi-arrow-counterclockwise"
viewBox="0 0 16 16"
>
<path
fillRule="evenodd"
d="M8 3a5 5 0 11-4.546 2.914.5.5 0 00-.908-.417A6 6 0 108 2v1z"
></path>
<path d="M8 4.466V.534a.25.25 0 00-.41-.192L5.23 2.308a.25.25 0 000 .384l2.36 1.966A.25.25 0 008 4.466z"></path>
</svg>
</button>
<button
type="button"
disabled={!canRedo}
onClick={(e) => {
e.preventDefault();
editor.dispatchCommand(REDO_COMMAND, undefined);
}}
className="p-2 mr-2"
aria-label="Redo"
>
<svg
xmlns="http://www.w3.org/2000/svg"
width="16"
height="16"
fill="currentColor"
className="bi bi-arrow-clockwise"
viewBox="0 0 16 16"
>
<path
fillRule="evenodd"
d="M8 3a5 5 0 104.546 2.914.5.5 0 01.908-.417A6 6 0 118 2v1z"
></path>
<path d="M8 4.466V.534a.25.25 0 01.41-.192l2.36 1.966c.12.1.12.284 0 .384L8.41 4.658A.25.25 0 018 4.466z"></path>
</svg>
</button>
<BlockTypeSelect editor={editor} toolbarRef={toolbarRef} blockType={blockType} />
<button
type="button"
onClick={(e) => {
e.preventDefault();
editor.dispatchCommand(FORMAT_TEXT_COMMAND, "bold");
}}
className={`p-2 mr-2 ${isBold ? "active" : ""}`}
aria-label="Format Bold"
>
<svg
xmlns="http://www.w3.org/2000/svg"
width="16"
height="16"
fill="currentColor"
className="bi bi-type-bold"
viewBox="0 0 16 16"
>
<path d="M8.21 13c2.106 0 3.412-1.087 3.412-2.823 0-1.306-.984-2.283-2.324-2.386v-.055a2.176 2.176 0 001.852-2.14c0-1.51-1.162-2.46-3.014-2.46H3.843V13H8.21zM5.908 4.674h1.696c.963 0 1.517.451 1.517 1.244 0 .834-.629 1.32-1.73 1.32H5.908V4.673zm0 6.788V8.598h1.73c1.217 0 1.88.492 1.88 1.415 0 .943-.643 1.449-1.832 1.449H5.907z"></path>
</svg>
</button>
<button
type="button"
onClick={(e) => {
e.preventDefault();
editor.dispatchCommand(FORMAT_TEXT_COMMAND, "italic");
}}
className={"p-2 mr-2" + (isItalic ? "active" : "")}
aria-label="Format Italics"
>
<svg
xmlns="http://www.w3.org/2000/svg"
width="16"
height="16"
fill="currentColor"
className="bi bi-type-italic"
viewBox="0 0 16 16"
>
<path d="M7.991 11.674L9.53 4.455c.123-.595.246-.71 1.347-.807l.11-.52H7.211l-.11.52c1.06.096 1.128.212 1.005.807L6.57 11.674c-.123.595-.246.71-1.346.806l-.11.52h3.774l.11-.52c-1.06-.095-1.129-.211-1.006-.806z"></path>
</svg>
</button>
<button
type="button"
onClick={(e) => {
e.preventDefault();
editor.dispatchCommand(FORMAT_TEXT_COMMAND, "underline");
}}
className={"p-2 mr-2" + (isUnderline ? "active" : "")}
aria-label="Format Underline"
>
<svg
xmlns="http://www.w3.org/2000/svg"
width="16"
height="16"
fill="currentColor"
className="bi bi-type-underline"
viewBox="0 0 16 16"
>
<path d="M5.313 3.136h-1.23V9.54c0 2.105 1.47 3.623 3.917 3.623s3.917-1.518 3.917-3.623V3.136h-1.23v6.323c0 1.49-.978 2.57-2.687 2.57-1.709 0-2.687-1.08-2.687-2.57V3.136zM12.5 15h-9v-1h9v1z"></path>
</svg>
</button>
<button
type="button"
onClick={(e) => {
e.preventDefault();
editor.dispatchCommand(FORMAT_TEXT_COMMAND, "strikethrough");
}}
className={"p-2 mr-2" + (isStrikethrough ? "active" : "")}
aria-label="Format Strikethrough"
>
<svg
xmlns="http://www.w3.org/2000/svg"
width="16"
height="16"
fill="currentColor"
className="bi bi-type-strikethrough"
viewBox="0 0 16 16"
>
<path d="M6.333 5.686c0 .31.083.581.27.814H5.166a2.776 2.776 0 01-.099-.76c0-1.627 1.436-2.768 3.48-2.768 1.969 0 3.39 1.175 3.445 2.85h-1.23c-.11-1.08-.964-1.743-2.25-1.743-1.23 0-2.18.602-2.18 1.607zm2.194 7.478c-2.153 0-3.589-1.107-3.705-2.81h1.23c.144 1.06 1.129 1.703 2.544 1.703 1.34 0 2.31-.705 2.31-1.675 0-.827-.547-1.374-1.914-1.675L8.046 8.5H1v-1h14v1h-3.504c.468.437.675.994.675 1.697 0 1.826-1.436 2.967-3.644 2.967z"></path>
</svg>
</button>
<button
type="button"
onClick={(e) => {
e.preventDefault();
editor.dispatchCommand(FORMAT_TEXT_COMMAND, "code");
}}
className={"p-2 mr-2 " + (isCode ? "active" : "")}
aria-label="Insert Code"
>
<svg
xmlns="http://www.w3.org/2000/svg"
width="16"
height="16"
fill="currentColor"
className="bi bi-code"
viewBox="0 0 16 16"
>
<path d="M5.854 4.854a.5.5 0 10-.708-.708l-3.5 3.5a.5.5 0 000 .708l3.5 3.5a.5.5 0 00.708-.708L2.707 8l3.147-3.146zm4.292 0a.5.5 0 01.708-.708l3.5 3.5a.5.5 0 010 .708l-3.5 3.5a.5.5 0 01-.708-.708L13.293 8l-3.147-3.146z"></path>
</svg>
</button>
<button
type="button"
onClick={insertLink}
className={"p-2 mr-2 " + (isLink ? "active" : "")}
aria-label="Insert Link"
>
<svg
xmlns="http://www.w3.org/2000/svg"
width="16"
height="16"
fill="currentColor"
className="bi bi-link"
viewBox="0 0 16 16"
>
<path d="M6.354 5.5H4a3 3 0 000 6h3a3 3 0 002.83-4H9c-.086 0-.17.01-.25.031A2 2 0 017 10.5H4a2 2 0 110-4h1.535c.218-.376.495-.714.82-1z"></path>
<path d="M9 5.5a3 3 0 00-2.83 4h1.098A2 2 0 019 6.5h3a2 2 0 110 4h-1.535a4.02 4.02 0 01-.82 1H12a3 3 0 100-6H9z"></path>
</svg>
</button>
{isLink && createPortal(<FloatingLinkEditor editor={editor} />, document.body)}
<button
type="button"
onClick={(e) => {
e.preventDefault();
editor.dispatchCommand(FORMAT_ELEMENT_COMMAND, "left");
}}
className="p-2 mr-2"
aria-label="Left Align"
>
<svg
xmlns="http://www.w3.org/2000/svg"
width="16"
height="16"
fill="currentColor"
className="bi bi-text-left"
viewBox="0 0 16 16"
>
<path
fillRule="evenodd"
d="M2 12.5a.5.5 0 01.5-.5h7a.5.5 0 010 1h-7a.5.5 0 01-.5-.5zm0-3a.5.5 0 01.5-.5h11a.5.5 0 010 1h-11a.5.5 0 01-.5-.5zm0-3a.5.5 0 01.5-.5h7a.5.5 0 010 1h-7a.5.5 0 01-.5-.5zm0-3a.5.5 0 01.5-.5h11a.5.5 0 010 1h-11a.5.5 0 01-.5-.5z"
></path>
</svg>
</button>
<button
type="button"
onClick={(e) => {
e.preventDefault();
editor.dispatchCommand(FORMAT_ELEMENT_COMMAND, "center");
}}
className="p-2 mr-2"
aria-label="Center Align"
>
<svg
xmlns="http://www.w3.org/2000/svg"
width="16"
height="16"
fill="currentColor"
className="bi bi-text-center"
viewBox="0 0 16 16"
>
<path
fillRule="evenodd"
d="M4 12.5a.5.5 0 01.5-.5h7a.5.5 0 010 1h-7a.5.5 0 01-.5-.5zm-2-3a.5.5 0 01.5-.5h11a.5.5 0 010 1h-11a.5.5 0 01-.5-.5zm2-3a.5.5 0 01.5-.5h7a.5.5 0 010 1h-7a.5.5 0 01-.5-.5zm-2-3a.5.5 0 01.5-.5h11a.5.5 0 010 1h-11a.5.5 0 01-.5-.5z"
></path>
</svg>
</button>
<button
type="button"
onClick={(e) => {
e.preventDefault();
editor.dispatchCommand(FORMAT_ELEMENT_COMMAND, "right");
}}
className="p-2 mr-2"
aria-label="Right Align"
>
<svg
xmlns="http://www.w3.org/2000/svg"
width="16"
height="16"
fill="currentColor"
className="bi bi-text-right"
viewBox="0 0 16 16"
>
<path
fillRule="evenodd"
d="M6 12.5a.5.5 0 01.5-.5h7a.5.5 0 010 1h-7a.5.5 0 01-.5-.5zm-4-3a.5.5 0 01.5-.5h11a.5.5 0 010 1h-11a.5.5 0 01-.5-.5zm4-3a.5.5 0 01.5-.5h7a.5.5 0 010 1h-7a.5.5 0 01-.5-.5zm-4-3a.5.5 0 01.5-.5h11a.5.5 0 010 1h-11a.5.5 0 01-.5-.5z"
></path>
</svg>
</button>
<button
type="button"
onClick={(e) => {
e.preventDefault();
editor.dispatchCommand(FORMAT_ELEMENT_COMMAND, "justify");
}}
className="p-2 mr-2"
aria-label="Justify Align"
>
<svg
xmlns="http://www.w3.org/2000/svg"
width="16"
height="16"
fill="currentColor"
className="bi bi-justify"
viewBox="0 0 16 16"
>
<path
fillRule="evenodd"
d="M2 12.5a.5.5 0 01.5-.5h11a.5.5 0 010 1h-11a.5.5 0 01-.5-.5zm0-3a.5.5 0 01.5-.5h11a.5.5 0 010 1h-11a.5.5 0 01-.5-.5zm0-3a.5.5 0 01.5-.5h11a.5.5 0 010 1h-11a.5.5 0 01-.5-.5zm0-3a.5.5 0 01.5-.5h11a.5.5 0 010 1h-11a.5.5 0 01-.5-.5z"
></path>
</svg>
</button>{" "}
</div>
);
};

View File

@ -1,56 +0,0 @@
import { LexicalComposer } from "@lexical/react/LexicalComposer";
import { ContentEditable } from "@lexical/react/LexicalContentEditable";
import { RichTextPlugin } from "@lexical/react/LexicalRichTextPlugin";
import { LinkPlugin } from "@lexical/react/LexicalLinkPlugin";
import { ListPlugin } from "@lexical/react/LexicalListPlugin";
import { HistoryPlugin } from "@lexical/react/LexicalHistoryPlugin";
import { MarkdownShortcutPlugin } from "@lexical/react/LexicalMarkdownShortcutPlugin";
import { TRANSFORMERS } from "@lexical/markdown";
// custom plugins
import { CodeHighlightPlugin } from "./plugins/code-highlight";
import ReadOnlyPlugin from "./plugins/read-only";
// config
import { initialConfig } from "./config";
// helpers
import { getValidatedValue } from "./helpers/editor";
import LexicalErrorBoundary from "@lexical/react/LexicalErrorBoundary";
export interface RichTextViewerProps {
value: string;
id: string;
}
const RichTextViewer: React.FC<RichTextViewerProps> = ({ value, id }) => {
return (
<LexicalComposer
initialConfig={{
...initialConfig,
namespace: id || "Lexical Editor",
editorState: getValidatedValue(value),
editable: false,
}}
>
<div className="relative">
<RichTextPlugin
contentEditable={
<ContentEditable className='className="h-[450px] outline-none resize-none overflow-hidden text-ellipsis' />
}
ErrorBoundary={LexicalErrorBoundary}
placeholder={
<div className="absolute top-[15px] left-[10px] pointer-events-none select-none text-gray-400">
Enter some text...
</div>
}
/>
<ReadOnlyPlugin value={value} />
<HistoryPlugin />
<CodeHighlightPlugin />
<ListPlugin />
<LinkPlugin />
<MarkdownShortcutPlugin transformers={TRANSFORMERS} />
</div>
</LexicalComposer>
);
};
export default RichTextViewer;

View File

@ -0,0 +1,35 @@
// next
import Image from "next/image";
// images
import Module from "public/onboarding/module.png";
const BreakIntoModules: React.FC = () => {
return (
<div className="h-full space-y-4">
<div className="relative h-1/2">
<div
className="absolute bottom-0 z-10 h-8 w-full bg-white"
style={{
background: "linear-gradient(0deg, #fff 84.2%, rgba(255, 255, 255, 0) 34.35%)",
}}
></div>
<Image
src={Module}
className="h-full"
objectFit="contain"
layout="fill"
alt="Plane- Modules"
/>
</div>
<div className="mx-auto h-1/2 space-y-4 lg:w-1/2">
<h2 className="text-2xl font-medium">Break into Modules</h2>
<p className="text-sm text-gray-400">
Modules break your big think into Projects or Features, to help you organize better.
</p>
<p className="text-sm text-gray-400">4/5</p>
</div>
</div>
);
};
export default BreakIntoModules;

View File

@ -0,0 +1,26 @@
// next
import Image from "next/image";
// images
import Commands from "public/onboarding/command-menu.png";
const CommandMenu: React.FC = () => {
return (
<div className="h-full space-y-4">
<div className="h-1/2 space-y-4">
<h5 className="text-sm text-gray-500">Open the contextual menu with:</h5>
<div className="relative h-1/2">
<Image src={Commands} objectFit="contain" layout="fill" alt="Plane- Issues" />
</div>
</div>
<div className="mx-auto h-1/2 space-y-4 lg:w-2/3">
<h2 className="text-2xl font-medium">Command Menu</h2>
<p className="text-sm text-gray-400">
With Command Menu, you can create, update and navigate across the platform.
</p>
<p className="text-sm text-gray-400">5/5</p>
</div>
</div>
);
};
export default CommandMenu;

View File

@ -0,0 +1,81 @@
// types
import useToast from "lib/hooks/useToast";
import workspaceService from "lib/services/workspace.service";
import { useForm } from "react-hook-form";
import { IUser } from "types";
import MultiInput from "ui/multi-input";
import OutlineButton from "ui/outline-button";
type Props = {
setStep: React.Dispatch<React.SetStateAction<number>>;
workspace: any;
};
const InviteMembers: React.FC<Props> = ({ setStep, workspace }) => {
const { setToastAlert } = useToast();
const {
setValue,
watch,
handleSubmit,
formState: { isSubmitting },
} = useForm<IUser>();
const onSubmit = async (formData: IUser) => {
await workspaceService
.inviteWorkspace(workspace.slug, formData)
.then((res) => {
console.log(res);
setToastAlert({
type: "success",
title: "Invitations sent!",
});
setStep(4);
})
.catch((err) => {
console.log(err);
});
};
return (
<form
className="grid w-full place-items-center space-y-8"
onSubmit={handleSubmit(onSubmit)}
onKeyDown={(e) => {
if (e.code === "Enter") e.preventDefault();
}}
>
<div className="w-full space-y-8 rounded-lg bg-white p-8 md:w-2/5">
<div className="space-y-4">
<h2 className="text-2xl font-medium">Invite co-workers to your team</h2>
<div className="space-y-4">
<div className="col-span-2 space-y-2">
<MultiInput
label="Enter e-mails to invite"
name="emails"
placeholder="dummy@plane.so"
watch={watch}
setValue={setValue}
/>
</div>
</div>
</div>
<div className="mx-auto flex h-1/4 gap-2 lg:w-1/2">
<button
type="submit"
className="w-full rounded-md bg-gray-200 px-4 py-2 text-sm"
disabled={isSubmitting}
>
{isSubmitting ? "Inviting..." : "Invite"}
</button>
<OutlineButton theme="secondary" className="w-full" onClick={() => setStep(4)}>
Skip
</OutlineButton>
</div>
</div>
</form>
);
};
export default InviteMembers;

View File

@ -0,0 +1,36 @@
// next
import Image from "next/image";
// images
import Cycle from "public/onboarding/cycle.png";
const MoveWithCycles: React.FC = () => {
return (
<div className="h-full space-y-4">
<div className="relative h-1/2">
<div
className="absolute bottom-0 z-10 h-8 w-full bg-white"
style={{
background: "linear-gradient(0deg, #fff 84.2%, rgba(255, 255, 255, 0) 34.35%)",
}}
></div>
<Image
src={Cycle}
className="h-full"
objectFit="contain"
layout="fill"
alt="Plane- Cycles"
/>
</div>
<div className="mx-auto h-1/2 space-y-4 lg:w-2/3">
<h2 className="text-2xl font-medium">Move with Cycles</h2>
<p className="text-sm text-gray-400">
Cycles help you and your team to progress faster, similar to the sprints commonly used in
agile development.
</p>
<p className="text-sm text-gray-400">3/5</p>
</div>
</div>
);
};
export default MoveWithCycles;

View File

@ -0,0 +1,36 @@
// next
import Image from "next/image";
// images
import Issue from "public/onboarding/issue.png";
const PlanWithIssues: React.FC = () => {
return (
<div className="h-full space-y-4">
<div className="relative h-1/2">
<div
className="absolute bottom-0 z-10 h-8 w-full bg-white"
style={{
background: "linear-gradient(0deg, #fff 84.2%, rgba(255, 255, 255, 0) 34.35%)",
}}
></div>
<Image
src={Issue}
className="h-full"
objectFit="contain"
layout="fill"
alt="Plane- Issues"
/>
</div>
<div className="mx-auto h-1/2 space-y-4 lg:w-2/3">
<h2 className="text-2xl font-medium">Plan with Issues</h2>
<p className="text-sm text-gray-400">
The issue is the building block of the Plane. Most concepts in Plane are either associated
with issues and their properties.
</p>
<p className="text-sm text-gray-400">2/5</p>
</div>
</div>
);
};
export default PlanWithIssues;

View File

@ -0,0 +1,119 @@
import { useEffect } from "react";
import { useForm } from "react-hook-form";
// hooks
import useToast from "lib/hooks/useToast";
// services
import userService from "lib/services/user.service";
// ui
import { Input } from "ui";
// types
import { IUser } from "types";
const defaultValues: Partial<IUser> = {
first_name: "",
last_name: "",
role: "",
};
type Props = {
user?: IUser;
setStep: React.Dispatch<React.SetStateAction<number>>;
};
const UserDetails: React.FC<Props> = ({ user, setStep }) => {
const { setToastAlert } = useToast();
const {
register,
handleSubmit,
reset,
formState: { errors, isSubmitting },
} = useForm<IUser>({
defaultValues,
});
const onSubmit = (formData: IUser) => {
userService
.updateUser(formData)
.then(() => {
setToastAlert({
title: "User details updated successfully!",
type: "success",
});
setStep(2);
})
.catch((err) => {
console.log(err);
});
};
useEffect(() => {
if (user)
reset({
first_name: user.first_name,
last_name: user.last_name,
role: user.role,
});
}, [user, reset]);
return (
<form className="grid w-full place-items-center" onSubmit={handleSubmit(onSubmit)}>
<div className="w-full space-y-8 rounded-lg bg-white p-8 md:w-2/5">
<div className="grid grid-cols-2 gap-4">
<div>
<Input
label="First Name"
name="first_name"
placeholder="Enter first name"
autoComplete="off"
register={register}
validations={{
required: "First name is required",
}}
error={errors.first_name}
/>
</div>
<div>
<Input
label="Last Name"
name="last_name"
placeholder="Enter last name"
autoComplete="off"
register={register}
validations={{
required: "Last name is required",
}}
error={errors.last_name}
/>
</div>
<div className="col-span-2">
<Input
label="Role"
name="role"
placeholder="What is your role?"
autoComplete="off"
register={register}
validations={{
required: "Role is required",
}}
error={errors.role}
/>
</div>
</div>
<div className="mx-auto h-1/4 lg:w-1/2">
<button
type="submit"
className="w-full rounded-md bg-gray-200 px-4 py-2 text-sm"
disabled={isSubmitting}
>
{isSubmitting ? "Updating..." : "Continue"}
</button>
</div>
</div>
</form>
);
};
export default UserDetails;

View File

@ -0,0 +1,23 @@
// next
import Image from "next/image";
// icons
import Logo from "public/logo.png";
const Welcome: React.FC = () => {
return (
<div className="h-full space-y-4">
<div className="h-1/2">
<Image src={Logo} height={100} width={100} alt="Plane Logo" />
</div>
<div className="mx-auto h-1/2 space-y-4 lg:w-2/3">
<h2 className="text-2xl font-medium">Welcome to Plane</h2>
<p className="text-sm text-gray-400">
Plane helps you plan your issues, cycles, and product modules to ship faster.
</p>
<p className="text-sm text-gray-400">1/5</p>
</div>
</div>
);
};
export default Welcome;

View File

@ -0,0 +1,297 @@
import { useEffect, useState } from "react";
import Image from "next/image";
import useSWR from "swr";
import { Controller, useForm } from "react-hook-form";
import { Tab } from "@headlessui/react";
// hooks
import useToast from "lib/hooks/useToast";
// services
import workspaceService from "lib/services/workspace.service";
// ui
import { CustomSelect, Input } from "ui";
// constants
import { companySize } from "constants/";
// fetch-keys
import { USER_WORKSPACE_INVITATIONS } from "constants/fetch-keys";
// types
import { IWorkspace, IWorkspaceMemberInvitation } from "types";
type Props = {
setStep: React.Dispatch<React.SetStateAction<number>>;
setWorkspace: React.Dispatch<React.SetStateAction<any>>;
};
const defaultValues: Partial<IWorkspace> = {
name: "",
slug: "",
company_size: null,
};
const Workspace: React.FC<Props> = ({ setStep, setWorkspace }) => {
const [slugError, setSlugError] = useState(false);
const [isJoiningWorkspaces, setIsJoiningWorkspaces] = useState(false);
const [invitationsRespond, setInvitationsRespond] = useState<string[]>([]);
const { setToastAlert } = useToast();
const { data: invitations, mutate } = useSWR(USER_WORKSPACE_INVITATIONS, () =>
workspaceService.userWorkspaceInvitations()
);
const {
register,
handleSubmit,
control,
setValue,
reset,
formState: { errors, isSubmitting },
} = useForm<IWorkspace>({ defaultValues });
const handleCreateWorkspace = async (formData: IWorkspace) => {
await workspaceService
.workspaceSlugCheck(formData.slug)
.then((res) => {
if (res.status === true) {
workspaceService
.createWorkspace(formData)
.then((res) => {
console.log(res);
setToastAlert({
type: "success",
title: "Workspace created successfully!",
});
setWorkspace(res);
setStep(3);
})
.catch((err) => {
console.error(err);
});
} else setSlugError(true);
})
.catch((err) => {
console.error(err);
});
};
const handleInvitation = (
workspace_invitation: IWorkspaceMemberInvitation,
action: "accepted" | "withdraw"
) => {
if (action === "accepted") {
setInvitationsRespond((prevData) => {
return [...prevData, workspace_invitation.id];
});
} else if (action === "withdraw") {
setInvitationsRespond((prevData) => {
return prevData.filter((item: string) => item !== workspace_invitation.id);
});
}
};
const submitInvitations = async () => {
if (invitationsRespond.length <= 0) return;
setIsJoiningWorkspaces(true);
await workspaceService
.joinWorkspaces({ invitations: invitationsRespond })
.then(async () => {
await mutate();
setStep(4);
setIsJoiningWorkspaces(false);
})
.catch((err) => {
console.error(err);
setIsJoiningWorkspaces(false);
});
};
useEffect(() => {
reset(defaultValues);
}, [reset]);
return (
<div className="grid w-full place-items-center">
<Tab.Group as="div" className="w-full rounded-lg bg-white p-8 md:w-2/5">
<Tab.List
as="div"
className="grid grid-cols-2 items-center gap-2 rounded-lg bg-gray-100 p-2 text-sm"
>
<Tab
className={({ selected }) =>
`rounded-lg px-6 py-2 ${selected ? "bg-gray-300" : "hover:bg-gray-200"}`
}
>
New workspace
</Tab>
<Tab
className={({ selected }) =>
`rounded-lg px-6 py-2 ${selected ? "bg-gray-300" : "hover:bg-gray-200"}`
}
>
Invited workspaces
</Tab>
</Tab.List>
<Tab.Panels>
<Tab.Panel>
<form className="mt-4 space-y-8" onSubmit={handleSubmit(handleCreateWorkspace)}>
<div className="w-full space-y-4">
<div className="grid grid-cols-1 gap-4">
<div>
<Input
label="Workspace name"
name="name"
placeholder="Enter name"
autoComplete="off"
register={register}
onChange={(e) =>
setValue("slug", e.target.value.toLocaleLowerCase().replace(/ /g, "-"))
}
validations={{
required: "Workspace name is required",
}}
error={errors.name}
/>
</div>
<div>
<h6 className="text-gray-500">Workspace slug</h6>
<div className="flex items-center rounded-md border border-gray-300 px-3">
<span className="text-sm text-slate-600">{"https://app.plane.so/"}</span>
<Input
name="slug"
mode="transparent"
autoComplete="off"
register={register}
className="block w-full rounded-md bg-transparent py-2 px-0 text-sm focus:outline-none focus:ring-0"
/>
</div>
{slugError && (
<span className="-mt-3 text-sm text-red-500">
Workspace URL is already taken!
</span>
)}
</div>
<div>
<Controller
name="company_size"
control={control}
rules={{ required: "This field is required" }}
render={({ field: { value, onChange } }) => (
<CustomSelect
value={value}
onChange={onChange}
label={value ? value.toString() : "Select company size"}
input
>
{companySize?.map((item) => (
<CustomSelect.Option key={item.value} value={item.value}>
{item.label}
</CustomSelect.Option>
))}
</CustomSelect>
)}
/>
{errors.company_size && (
<span className="text-sm text-red-500">{errors.company_size.message}</span>
)}
</div>
</div>
</div>
<div className="mx-auto h-1/4 lg:w-1/2">
<button
type="submit"
className="w-full rounded-md bg-gray-200 px-4 py-2 text-sm"
disabled={isSubmitting}
>
{isSubmitting ? "Creating..." : "Continue"}
</button>
</div>
</form>
</Tab.Panel>
<Tab.Panel>
<div className="mt-4 space-y-8">
<div className="divide-y">
{invitations && invitations.length > 0 ? (
invitations.map((invitation) => (
<div key={invitation.id}>
<label
className={`group relative flex cursor-pointer items-start space-x-3 border-2 border-transparent py-4`}
htmlFor={invitation.id}
>
<div className="flex-shrink-0">
<span className="inline-flex h-10 w-10 items-center justify-center rounded-lg">
{invitation.workspace.logo && invitation.workspace.logo !== "" ? (
<Image
src={invitation.workspace.logo}
height="100%"
width="100%"
className="rounded"
alt={invitation.workspace.name}
/>
) : (
<span className="flex h-full w-full items-center justify-center rounded bg-gray-500 p-4 uppercase text-white">
{invitation.workspace.name.charAt(0)}
</span>
)}
</span>
</div>
<div className="min-w-0 flex-1">
<div className="text-sm font-medium text-gray-900">
{invitation.workspace.name}
</div>
<p className="text-sm text-gray-500">
Invited by {invitation.workspace.owner.first_name}
</p>
</div>
<div className="flex-shrink-0 self-center">
<input
id={invitation.id}
aria-describedby="workspaces"
name={invitation.id}
checked={invitationsRespond.includes(invitation.id)}
value={invitation.workspace.name}
onChange={(e) => {
handleInvitation(
invitation,
invitationsRespond.includes(invitation.id) ? "withdraw" : "accepted"
);
}}
type="checkbox"
className="h-4 w-4 rounded border-gray-300 text-theme focus:ring-indigo-500"
/>
</div>
</label>
</div>
))
) : (
<div className="text-center">
<h3 className="text-gray-400">You have no invitations</h3>
</div>
)}
</div>
<div className="mx-auto h-1/4 lg:w-1/2">
<button
type="submit"
className={`w-full rounded-md bg-gray-200 px-4 py-2 text-sm ${
isJoiningWorkspaces || invitationsRespond.length === 0
? "cursor-not-allowed opacity-80"
: ""
}`}
disabled={isJoiningWorkspaces || invitationsRespond.length === 0}
onClick={submitInvitations}
>
Join Workspace
</button>
</div>
</div>
</Tab.Panel>
</Tab.Panels>
</Tab.Group>
</div>
);
};
export default Workspace;

View File

@ -1,39 +1,53 @@
import React, { useEffect, useRef, useState } from "react";
// headless ui
import { mutate } from "swr";
import { Dialog, Transition } from "@headlessui/react";
// services
import projectService from "lib/services/project.service";
// constants
import { PROJECTS_LIST } from "constants/fetch-keys";
// hooks
import useUser from "lib/hooks/useUser";
import useToast from "lib/hooks/useToast";
// icons
import { ExclamationTriangleIcon } from "@heroicons/react/24/outline";
// ui
import { Button, Input } from "ui";
// types
import type { IProject } from "types";
import type { IProject, IWorkspace } from "types";
type Props = {
type TConfirmProjectDeletionProps = {
isOpen: boolean;
onClose: () => void;
onSuccess?: () => void;
data: IProject | null;
};
const ConfirmProjectDeletion: React.FC<Props> = ({ isOpen, data, onClose }) => {
const ConfirmProjectDeletion: React.FC<TConfirmProjectDeletionProps> = (props) => {
const { isOpen, data, onClose, onSuccess } = props;
const cancelButtonRef = useRef(null);
const [isDeleteLoading, setIsDeleteLoading] = useState(false);
const [selectedProject, setSelectedProject] = useState<IProject | null>(null);
const [confirmProjectName, setConfirmProjectName] = useState("");
const [confirmDeleteMyProject, setConfirmDeleteMyProject] = useState(false);
const [selectedProject, setSelectedProject] = useState<IProject | null>(null);
const canDelete = confirmProjectName === data?.name && confirmDeleteMyProject;
const { activeWorkspace, mutateProjects } = useUser();
const workspaceSlug = (data?.workspace as IWorkspace)?.slug;
const { setToastAlert } = useToast();
const cancelButtonRef = useRef(null);
const canDelete = confirmProjectName === data?.name && confirmDeleteMyProject;
useEffect(() => {
if (data) setSelectedProject(data);
else {
const timer = setTimeout(() => {
setSelectedProject(null);
clearTimeout(timer);
}, 300);
}
}, [data]);
const handleClose = () => {
setIsDeleteLoading(false);
@ -47,12 +61,15 @@ const ConfirmProjectDeletion: React.FC<Props> = ({ isOpen, data, onClose }) => {
const handleDeletion = async () => {
setIsDeleteLoading(true);
if (!data || !activeWorkspace || !canDelete) return;
if (!data || !workspaceSlug || !canDelete) return;
await projectService
.deleteProject(activeWorkspace.slug, data.id)
.deleteProject(workspaceSlug, data.id)
.then(() => {
handleClose();
mutateProjects((prevData) => (prevData ?? []).filter((item) => item.id !== data.id), false);
mutate<IProject[]>(PROJECTS_LIST(workspaceSlug), (prevData) =>
prevData?.filter((project: IProject) => project.id !== data.id)
);
if (onSuccess) onSuccess();
setToastAlert({
title: "Success",
type: "success",
@ -65,16 +82,6 @@ const ConfirmProjectDeletion: React.FC<Props> = ({ isOpen, data, onClose }) => {
});
};
useEffect(() => {
if (data) setSelectedProject(data);
else {
const timer = setTimeout(() => {
setSelectedProject(null);
clearTimeout(timer);
}, 300);
}
}, [data]);
return (
<Transition.Root show={isOpen} as={React.Fragment}>
<Dialog
@ -127,7 +134,7 @@ const ConfirmProjectDeletion: React.FC<Props> = ({ isOpen, data, onClose }) => {
removed. This action cannot be undone.
</p>
</div>
<div className="h-0.5 bg-gray-200 my-3" />
<div className="my-3 h-0.5 bg-gray-200" />
<div className="mt-3">
<p className="text-sm">
Enter the project name{" "}

View File

@ -1,24 +1,25 @@
import React, { useState, useEffect } from "react";
// swr
import { useRouter } from "next/router";
import useSWR, { mutate } from "swr";
// react hook form
import { useForm, Controller } from "react-hook-form";
// headless
import { Dialog, Transition } from "@headlessui/react";
// services
import projectServices from "lib/services/project.service";
import workspaceService from "lib/services/workspace.service";
// common
import { createSimilarString, getRandomEmoji } from "constants/common";
import { getRandomEmoji } from "constants/common";
// constants
import { NETWORK_CHOICES } from "constants/";
// fetch keys
import { PROJECTS_LIST, WORKSPACE_MEMBERS } from "constants/fetch-keys";
import { PROJECTS_LIST, WORKSPACE_MEMBERS_ME } from "constants/fetch-keys";
// hooks
import useUser from "lib/hooks/useUser";
import useToast from "lib/hooks/useToast";
// ui
import { Button, Input, TextArea, Select, EmojiIconPicker } from "ui";
import { Button, Input, TextArea, EmojiIconPicker, CustomSelect } from "ui";
// types
import { IProject } from "types";
@ -31,7 +32,7 @@ const defaultValues: Partial<IProject> = {
name: "",
identifier: "",
description: "",
network: 0,
network: 2,
icon: getRandomEmoji(),
};
@ -52,53 +53,65 @@ const IsGuestCondition: React.FC<{
return null;
};
const CreateProjectModal: React.FC<Props> = ({ isOpen, setIsOpen }) => {
const handleClose = () => {
setIsOpen(false);
const timeout = setTimeout(() => {
reset(defaultValues);
clearTimeout(timeout);
}, 500);
};
export const CreateProjectModal: React.FC<Props> = (props) => {
const { isOpen, setIsOpen } = props;
const { activeWorkspace, user } = useUser();
const [isChangeIdentifierRequired, setIsChangeIdentifierRequired] = useState(true);
const { data: workspaceMembers } = useSWR(
activeWorkspace ? WORKSPACE_MEMBERS(activeWorkspace.slug) : null,
activeWorkspace ? () => workspaceService.workspaceMembers(activeWorkspace.slug) : null,
const { setToastAlert } = useToast();
const {
query: { workspaceSlug },
} = useRouter();
const { data: myWorkspaceMembership } = useSWR(
workspaceSlug ? WORKSPACE_MEMBERS_ME(workspaceSlug as string) : null,
workspaceSlug ? () => workspaceService.workspaceMemberMe(workspaceSlug as string) : null,
{
shouldRetryOnError: false,
}
);
const [recommendedIdentifier, setRecommendedIdentifier] = useState<string[]>([]);
const { setToastAlert } = useToast();
const [isChangeIdentifierRequired, setIsChangeIdentifierRequired] = useState(true);
const {
register,
formState: { errors, isSubmitting },
handleSubmit,
reset,
setError,
clearErrors,
control,
watch,
setValue,
} = useForm<IProject>({
defaultValues,
mode: "all",
reValidateMode: "onChange",
});
const projectName = watch("name") ?? "";
const projectIdentifier = watch("identifier") ?? "";
useEffect(() => {
if (projectName && isChangeIdentifierRequired) {
setValue("identifier", projectName.replace(/ /g, "").toUpperCase().substring(0, 3));
}
}, [projectName, projectIdentifier, setValue, isChangeIdentifierRequired]);
useEffect(() => {
return () => setIsChangeIdentifierRequired(true);
}, [isOpen]);
const handleClose = () => {
setIsOpen(false);
reset(defaultValues);
};
const onSubmit = async (formData: IProject) => {
if (!activeWorkspace) return;
if (!workspaceSlug) return;
await projectServices
.createProject(activeWorkspace.slug, formData)
.createProject(workspaceSlug as string, formData)
.then((res) => {
console.log(res);
mutate<IProject[]>(
PROJECTS_LIST(activeWorkspace.slug),
PROJECTS_LIST(workspaceSlug as string),
(prevData) => [res, ...(prevData ?? [])],
false
);
@ -129,45 +142,14 @@ const CreateProjectModal: React.FC<Props> = ({ isOpen, setIsOpen }) => {
});
};
const projectName = watch("name") ?? "";
const projectIdentifier = watch("identifier") ?? "";
useEffect(() => {
if (projectName && isChangeIdentifierRequired) {
setValue("identifier", projectName.replace(/ /g, "").toUpperCase().substring(0, 3));
}
}, [projectName, projectIdentifier, setValue, isChangeIdentifierRequired]);
useEffect(() => {
if (!projectName) return;
const suggestedIdentifier = createSimilarString(
projectName.replace(/ /g, "").toUpperCase().substring(0, 3)
);
setRecommendedIdentifier([
suggestedIdentifier + Math.floor(Math.random() * 101),
suggestedIdentifier + Math.floor(Math.random() * 101),
projectIdentifier.toUpperCase().substring(0, 3) + Math.floor(Math.random() * 101),
projectIdentifier.toUpperCase().substring(0, 3) + Math.floor(Math.random() * 101),
]);
}, [errors.identifier, projectIdentifier, projectName]);
useEffect(() => {
return () => setIsChangeIdentifierRequired(true);
}, [isOpen]);
if (workspaceMembers) {
const isMember = workspaceMembers.find((member) => member.member.id === user?.id);
const isGuest = workspaceMembers.find(
(member) => member.member.id === user?.id && member.role === 5
);
if ((!isMember || isGuest) && isOpen) return <IsGuestCondition setIsOpen={setIsOpen} />;
// FIXME: remove this and authorize using getServerSideProps
if (myWorkspaceMembership && isOpen) {
if (myWorkspaceMembership.role <= 10) return <IsGuestCondition setIsOpen={setIsOpen} />;
}
return (
<Transition.Root show={isOpen} as={React.Fragment}>
<Dialog as="div" className="relative z-10" onClose={handleClose}>
<Dialog as="div" className="relative z-20" onClose={handleClose}>
<Transition.Child
as={React.Fragment}
enter="ease-out duration-300"
@ -180,7 +162,7 @@ const CreateProjectModal: React.FC<Props> = ({ isOpen, setIsOpen }) => {
<div className="fixed inset-0 bg-gray-500 bg-opacity-75 transition-opacity" />
</Transition.Child>
<div className="fixed inset-0 z-10 overflow-y-auto">
<div className="fixed inset-0 z-20 overflow-y-auto">
<div className="flex min-h-full items-center justify-center p-4 text-center sm:p-0">
<Transition.Child
as={React.Fragment}
@ -203,51 +185,67 @@ const CreateProjectModal: React.FC<Props> = ({ isOpen, setIsOpen }) => {
</p>
</div>
<div className="space-y-3">
<div className="flex items-center gap-3">
<div className="flex-shrink-0">
<label htmlFor="icon" className="mb-2 text-gray-500">
Icon
</label>
<Controller
control={control}
name="icon"
render={({ field: { value, onChange } }) => (
<EmojiIconPicker
label={
value ? String.fromCodePoint(parseInt(value)) : "Select Icon"
}
value={value}
onChange={onChange}
/>
)}
/>
</div>
<div className="w-full">
<Input
id="name"
label="Name"
name="name"
type="name"
placeholder="Enter name"
error={errors.name}
register={register}
validations={{
required: "Name is required",
}}
/>
</div>
</div>
<div>
<label htmlFor="icon" className="text-gray-500 mb-2">
Icon
</label>
<h6 className="text-gray-500">Network</h6>
<Controller
name="network"
control={control}
name="icon"
render={({ field: { value, onChange } }) => (
<EmojiIconPicker
label={value ? String.fromCodePoint(parseInt(value)) : "Select Icon"}
render={({ field: { onChange, value } }) => (
<CustomSelect
value={value}
onChange={onChange}
/>
label={
Object.keys(NETWORK_CHOICES).find((k) => k === value.toString())
? NETWORK_CHOICES[
value.toString() as keyof typeof NETWORK_CHOICES
]
: "Select network"
}
input
>
{Object.keys(NETWORK_CHOICES).map((key) => (
<CustomSelect.Option key={key} value={key}>
{NETWORK_CHOICES[key as keyof typeof NETWORK_CHOICES]}
</CustomSelect.Option>
))}
</CustomSelect>
)}
/>
</div>
<div>
<Input
id="name"
label="Name"
name="name"
type="name"
placeholder="Enter name"
error={errors.name}
register={register}
validations={{
required: "Name is required",
}}
/>
</div>
<div>
<Select
name="network"
id="network"
options={Object.keys(NETWORK_CHOICES).map((key) => ({
value: key,
label: NETWORK_CHOICES[key as keyof typeof NETWORK_CHOICES],
}))}
label="Network"
register={register}
validations={{
required: "Network is required",
}}
/>
</div>
<div>
<TextArea
id="description"
@ -270,6 +268,8 @@ const CreateProjectModal: React.FC<Props> = ({ isOpen, setIsOpen }) => {
onChange={() => setIsChangeIdentifierRequired(false)}
validations={{
required: "Identifier is required",
validate: (value) =>
/^[A-Z]+$/.test(value) || "Identifier must be uppercase text.",
minLength: {
value: 1,
message: "Identifier must at least be of 1 character",
@ -280,31 +280,10 @@ const CreateProjectModal: React.FC<Props> = ({ isOpen, setIsOpen }) => {
},
}}
/>
{errors.identifier && (
<div className="mt-2">
<p>Ops! Identifier is already taken. Try one of the following:</p>
<div className="flex gap-x-2">
{recommendedIdentifier.map((identifier) => (
<button
key={identifier}
type="button"
className="text-sm text-gray-500 hover:text-gray-700 border p-2 py-0.5 rounded"
onClick={() => {
clearErrors("identifier");
setValue("identifier", identifier);
setIsChangeIdentifierRequired(false);
}}
>
{identifier}
</button>
))}
</div>
</div>
)}
</div>
</div>
</div>
<div className="mt-5 sm:mt-6 sm:grid sm:grid-flow-row-dense sm:grid-cols-2 sm:gap-3">
<div className="mt-5 flex justify-end gap-2">
<Button theme="secondary" onClick={handleClose}>
Cancel
</Button>
@ -321,5 +300,3 @@ const CreateProjectModal: React.FC<Props> = ({ isOpen, setIsOpen }) => {
</Transition.Root>
);
};
export default CreateProjectModal;

View File

@ -1,10 +1,19 @@
import React, { useCallback } from "react";
// swr
import useSWR, { mutate } from "swr";
// services
import stateService from "lib/services/state.service";
// constants
import { STATE_LIST, CYCLE_ISSUES } from "constants/fetch-keys";
// components
import SingleBoard from "components/project/cycles/board-view/single-board";
// ui
import { Spinner } from "ui";
// types
import { IIssue, IProjectMember, NestedKeyOf, Properties } from "types";
import useUser from "lib/hooks/useUser";
import { CycleIssueResponse, IIssue, IProjectMember, NestedKeyOf, Properties } from "types";
import { useRouter } from "next/router";
import { DragDropContext, DropResult } from "react-beautiful-dnd";
import issuesService from "lib/services/issues.service";
type Props = {
groupedByIssues: {
@ -28,64 +37,140 @@ type Props = {
>;
};
const CyclesBoardView: React.FC<Props> = ({
groupedByIssues,
properties,
selectedGroup,
members,
openCreateIssueModal,
openIssuesListModal,
removeIssueFromCycle,
partialUpdateIssue,
handleDeleteIssue,
setPreloadedData,
}) => {
const { states } = useUser();
const CyclesBoardView: React.FC<Props> = (props) => {
const {
groupedByIssues,
properties,
selectedGroup,
members,
openCreateIssueModal,
openIssuesListModal,
removeIssueFromCycle,
partialUpdateIssue,
handleDeleteIssue,
setPreloadedData,
} = props;
const router = useRouter();
const { workspaceSlug, projectId, cycleId } = router.query;
const { data: states } = useSWR(
workspaceSlug && projectId ? STATE_LIST(projectId as string) : null,
workspaceSlug && projectId
? () => stateService.getStates(workspaceSlug as string, projectId as string)
: null
);
const handleOnDragEnd = useCallback(
(result: DropResult) => {
if (!result.destination) return;
const { source, destination } = result;
if (source.droppableId !== destination.droppableId) {
const sourceGroup = source.droppableId; // source group id
const destinationGroup = destination.droppableId; // destination group id
if (!sourceGroup || !destinationGroup) return;
// removed/dragged item
const removedItem = groupedByIssues[source.droppableId][source.index];
if (selectedGroup === "priority") {
// update the removed item for mutation
removedItem.priority = destinationGroup;
// patch request
issuesService.patchIssue(workspaceSlug as string, projectId as string, removedItem.id, {
priority: destinationGroup,
});
} else if (selectedGroup === "state_detail.name") {
const destinationState = states?.find((s) => s.name === destinationGroup);
const destinationStateId = destinationState?.id;
// update the removed item for mutation
if (!destinationStateId || !destinationState) return;
removedItem.state = destinationStateId;
removedItem.state_detail = destinationState;
// patch request
issuesService.patchIssue(workspaceSlug as string, projectId as string, removedItem.id, {
state: destinationStateId,
});
if (!cycleId) return;
mutate<CycleIssueResponse[]>(
CYCLE_ISSUES(cycleId as string),
(prevData) => {
if (!prevData) return prevData;
const updatedIssues = prevData.map((issue) => {
if (issue.issue_detail.id === removedItem.id) {
return {
...issue,
issue_detail: removedItem,
};
}
return issue;
});
return [...updatedIssues];
},
false
);
}
// remove item from the source group
groupedByIssues[source.droppableId].splice(source.index, 1);
// add item to the destination group
groupedByIssues[destination.droppableId].splice(destination.index, 0, removedItem);
}
},
[workspaceSlug, groupedByIssues, projectId, selectedGroup, states, cycleId]
);
return (
<>
{groupedByIssues ? (
<div className="h-full w-full">
<div className="h-full w-full overflow-hidden">
<div className="h-full w-full">
<div className="flex gap-x-4 h-full overflow-x-auto overflow-y-hidden pb-3">
{Object.keys(groupedByIssues).map((singleGroup) => (
<SingleBoard
key={singleGroup}
selectedGroup={selectedGroup}
groupTitle={singleGroup}
createdBy={
selectedGroup === "created_by"
? members?.find((m) => m.member.id === singleGroup)?.member.first_name ??
"loading..."
: null
}
groupedByIssues={groupedByIssues}
bgColor={
selectedGroup === "state_detail.name"
? states?.find((s) => s.name === singleGroup)?.color
: undefined
}
properties={properties}
removeIssueFromCycle={removeIssueFromCycle}
openIssuesListModal={openIssuesListModal}
openCreateIssueModal={openCreateIssueModal}
partialUpdateIssue={partialUpdateIssue}
handleDeleteIssue={handleDeleteIssue}
setPreloadedData={setPreloadedData}
stateId={
selectedGroup === "state_detail.name"
? states?.find((s) => s.name === singleGroup)?.id ?? null
: null
}
/>
))}
<DragDropContext onDragEnd={handleOnDragEnd}>
<div className="h-full w-full overflow-hidden">
<div className="h-full w-full">
<div className="flex h-full gap-x-4 overflow-x-auto overflow-y-hidden pb-3">
{Object.keys(groupedByIssues).map((singleGroup) => (
<SingleBoard
key={singleGroup}
selectedGroup={selectedGroup}
groupTitle={singleGroup}
createdBy={
selectedGroup === "created_by"
? members?.find((m) => m.member.id === singleGroup)?.member.first_name ??
"loading..."
: null
}
groupedByIssues={groupedByIssues}
bgColor={
selectedGroup === "state_detail.name"
? states?.find((s) => s.name === singleGroup)?.color
: "#000000"
}
properties={properties}
removeIssueFromCycle={removeIssueFromCycle}
openIssuesListModal={openIssuesListModal}
openCreateIssueModal={openCreateIssueModal}
partialUpdateIssue={partialUpdateIssue}
handleDeleteIssue={handleDeleteIssue}
setPreloadedData={setPreloadedData}
stateId={
selectedGroup === "state_detail.name"
? states?.find((s) => s.name === singleGroup)?.id ?? null
: null
}
/>
))}
</div>
</div>
</div>
</div>
</DragDropContext>
</div>
) : (
<div className="h-full w-full flex justify-center items-center">
<div className="flex h-full w-full items-center justify-center">
<Spinner />
</div>
)}

View File

@ -4,8 +4,6 @@ import React, { useState } from "react";
import useSWR from "swr";
// services
import workspaceService from "lib/services/workspace.service";
// hooks
import useUser from "lib/hooks/useUser";
// components
import SingleIssue from "components/common/board-view/single-issue";
// ui
@ -18,6 +16,9 @@ import { IIssue, IWorkspaceMember, NestedKeyOf, Properties } from "types";
import { WORKSPACE_MEMBERS } from "constants/fetch-keys";
// common
import { addSpaceIfCamelCase } from "constants/common";
import { useRouter } from "next/router";
import StrictModeDroppable from "components/dnd/StrictModeDroppable";
import { Draggable } from "react-beautiful-dnd";
type Props = {
properties: Properties;
@ -59,10 +60,12 @@ const SingleModuleBoard: React.FC<Props> = ({
setPreloadedData,
stateId,
}) => {
// Collapse/Expand
const [show, setState] = useState(true);
// TODO: will use this to collapse/expand the board
const [isCollapsed, setIsCollapsed] = useState(true);
const { activeWorkspace } = useUser();
const router = useRouter();
const { workspaceSlug } = router.query;
if (selectedGroup === "priority")
groupTitle === "high"
@ -74,26 +77,26 @@ const SingleModuleBoard: React.FC<Props> = ({
: (bgColor = "#ff0000");
const { data: people } = useSWR<IWorkspaceMember[]>(
activeWorkspace ? WORKSPACE_MEMBERS : null,
activeWorkspace ? () => workspaceService.workspaceMembers(activeWorkspace.slug) : null
workspaceSlug ? WORKSPACE_MEMBERS : null,
workspaceSlug ? () => workspaceService.workspaceMembers(workspaceSlug as string) : null
);
return (
<div className={`rounded flex-shrink-0 h-full ${!show ? "" : "w-80 bg-gray-50 border"}`}>
<div className={`${!show ? "" : "h-full space-y-3 overflow-y-auto flex flex-col"}`}>
<div className={`h-full flex-shrink-0 rounded ${!isCollapsed ? "" : "w-80 border bg-gray-50"}`}>
<div className={`${!isCollapsed ? "" : "flex h-full flex-col space-y-3 overflow-y-auto"}`}>
<div
className={`flex justify-between p-3 pb-0 ${
!show ? "flex-col bg-gray-50 rounded-md border" : ""
!isCollapsed ? "flex-col rounded-md border bg-gray-50" : ""
}`}
>
<div
className={`w-full flex justify-between items-center ${
!show ? "flex-col gap-2" : "gap-1"
className={`flex w-full items-center justify-between ${
!isCollapsed ? "flex-col gap-2" : "gap-1"
}`}
>
<div
className={`flex items-center gap-x-1 px-2 bg-slate-900 rounded-md cursor-pointer ${
!show ? "py-2 mb-2 flex-col gap-y-2" : ""
className={`flex cursor-pointer items-center gap-x-1 rounded-md bg-slate-900 px-2 ${
!isCollapsed ? "mb-2 flex-col gap-y-2 py-2" : ""
}`}
style={{
border: `2px solid ${bgColor}`,
@ -103,7 +106,7 @@ const SingleModuleBoard: React.FC<Props> = ({
<h2
className={`text-[0.9rem] font-medium capitalize`}
style={{
writingMode: !show ? "vertical-rl" : "horizontal-tb",
writingMode: !isCollapsed ? "vertical-rl" : "horizontal-tb",
}}
>
{groupTitle === null || groupTitle === "null"
@ -112,7 +115,7 @@ const SingleModuleBoard: React.FC<Props> = ({
? createdBy
: addSpaceIfCamelCase(groupTitle)}
</h2>
<span className="text-gray-500 text-sm ml-0.5">
<span className="ml-0.5 text-sm text-gray-500">
{groupedByIssues[groupTitle].length}
</span>
</div>
@ -138,68 +141,85 @@ const SingleModuleBoard: React.FC<Props> = ({
</CustomMenu>
</div>
</div>
<div
className={`mt-3 space-y-3 h-full overflow-y-auto px-3 pb-3 ${
!show ? "hidden" : "block"
}`}
>
{groupedByIssues[groupTitle].map((childIssue, index: number) => {
const assignees = [
...(childIssue?.assignees_list ?? []),
...(childIssue?.assignees ?? []),
]?.map((assignee) => {
const tempPerson = people?.find((p) => p.member.id === assignee)?.member;
return {
avatar: tempPerson?.avatar,
first_name: tempPerson?.first_name,
email: tempPerson?.email,
};
});
return (
<SingleIssue
key={childIssue.id}
issue={childIssue}
properties={properties}
assignees={assignees}
people={people}
partialUpdateIssue={partialUpdateIssue}
handleDeleteIssue={handleDeleteIssue}
/>
);
})}
<CustomMenu
label={
<span className="flex items-center gap-1">
<PlusIcon className="h-3 w-3" />
Add issue
</span>
}
className="mt-1"
optionsPosition="left"
withoutBorder
>
<CustomMenu.MenuItem
onClick={() => {
openCreateIssueModal();
if (selectedGroup !== null) {
setPreloadedData({
state: stateId !== null ? stateId : undefined,
[selectedGroup]: groupTitle,
actionType: "createIssue",
});
}
}}
<StrictModeDroppable key={groupTitle} droppableId={groupTitle}>
{(provided, snapshot) => (
<div
className={`mt-3 h-full space-y-3 overflow-y-auto px-3 pb-3 ${
snapshot.isDraggingOver ? "bg-indigo-50 bg-opacity-50" : ""
} ${!isCollapsed ? "hidden" : "block"}`}
{...provided.droppableProps}
ref={provided.innerRef}
>
Create new
</CustomMenu.MenuItem>
<CustomMenu.MenuItem onClick={() => openIssuesListModal()}>
Add an existing issue
</CustomMenu.MenuItem>
</CustomMenu>
</div>
{groupedByIssues[groupTitle].map((childIssue, index: number) => {
const assignees = [
...(childIssue?.assignees_list ?? []),
...(childIssue?.assignees ?? []),
]?.map((assignee) => {
const tempPerson = people?.find((p) => p.member.id === assignee)?.member;
return {
avatar: tempPerson?.avatar,
first_name: tempPerson?.first_name,
email: tempPerson?.email,
};
});
return (
<Draggable key={childIssue.id} draggableId={childIssue.id} index={index}>
{(provided, snapshot) => (
<div
ref={provided.innerRef}
{...provided.draggableProps}
{...provided.dragHandleProps}
>
<SingleIssue
issue={childIssue}
properties={properties}
snapshot={snapshot}
assignees={assignees}
people={people}
partialUpdateIssue={partialUpdateIssue}
handleDeleteIssue={handleDeleteIssue}
/>
</div>
)}
</Draggable>
);
})}
{provided.placeholder}
<CustomMenu
label={
<span className="flex items-center gap-1">
<PlusIcon className="h-3 w-3" />
Add issue
</span>
}
className="mt-1"
optionsPosition="left"
noBorder
>
<CustomMenu.MenuItem
onClick={() => {
openCreateIssueModal();
if (selectedGroup !== null) {
setPreloadedData({
state: stateId !== null ? stateId : undefined,
[selectedGroup]: groupTitle,
actionType: "createIssue",
});
}
}}
>
Create new
</CustomMenu.MenuItem>
<CustomMenu.MenuItem onClick={() => openIssuesListModal()}>
Add an existing issue
</CustomMenu.MenuItem>
</CustomMenu>
</div>
)}
</StrictModeDroppable>
</div>
</div>
);

View File

@ -1,4 +1,6 @@
import React, { useEffect, useRef, useState } from "react";
// next
import { useRouter } from "next/router";
// swr
import { mutate } from "swr";
// headless ui
@ -7,8 +9,6 @@ import { Dialog, Transition } from "@headlessui/react";
import cycleService from "lib/services/cycles.service";
// fetch api
import { CYCLE_LIST } from "constants/fetch-keys";
// hooks
import useUser from "lib/hooks/useUser";
// icons
import { ExclamationTriangleIcon } from "@heroicons/react/24/outline";
// ui
@ -16,18 +16,24 @@ import { Button } from "ui";
// types
import type { ICycle } from "types";
type Props = {
type TConfirmCycleDeletionProps = {
isOpen: boolean;
setIsOpen: React.Dispatch<React.SetStateAction<boolean>>;
data?: ICycle;
};
const ConfirmCycleDeletion: React.FC<Props> = ({ isOpen, setIsOpen, data }) => {
const [isDeleteLoading, setIsDeleteLoading] = useState(false);
const { activeWorkspace } = useUser();
const ConfirmCycleDeletion: React.FC<TConfirmCycleDeletionProps> = (props) => {
const { isOpen, setIsOpen, data } = props;
const cancelButtonRef = useRef(null);
const [isDeleteLoading, setIsDeleteLoading] = useState(false);
const router = useRouter();
const { workspaceSlug } = router.query;
useEffect(() => {
data && setIsOpen(true);
}, [data, setIsOpen]);
const handleClose = () => {
setIsOpen(false);
@ -36,9 +42,9 @@ const ConfirmCycleDeletion: React.FC<Props> = ({ isOpen, setIsOpen, data }) => {
const handleDeletion = async () => {
setIsDeleteLoading(true);
if (!data || !activeWorkspace) return;
if (!data || !workspaceSlug) return;
await cycleService
.deleteCycle(activeWorkspace.slug, data.project, data.id)
.deleteCycle(workspaceSlug as string, data.project, data.id)
.then(() => {
mutate<ICycle[]>(
CYCLE_LIST(data.project),
@ -53,10 +59,6 @@ const ConfirmCycleDeletion: React.FC<Props> = ({ isOpen, setIsOpen, data }) => {
});
};
useEffect(() => {
data && setIsOpen(true);
}, [data, setIsOpen]);
return (
<Transition.Root show={isOpen} as={React.Fragment}>
<Dialog

View File

@ -1,20 +1,20 @@
import React, { useEffect } from "react";
// next
import { useRouter } from "next/router";
// swr
import { mutate } from "swr";
// react hook form
import { useForm } from "react-hook-form";
import { Controller, useForm } from "react-hook-form";
// headless
import { Dialog, Transition } from "@headlessui/react";
// services
import cycleService from "lib/services/cycles.service";
// fetch keys
import { CYCLE_LIST } from "constants/fetch-keys";
// hooks
import useUser from "lib/hooks/useUser";
// common
import { renderDateFormat } from "constants/common";
// ui
import { Button, Input, TextArea, Select } from "ui";
import { Button, Input, TextArea, Select, CustomSelect } from "ui";
// types
import type { ICycle } from "types";
@ -28,31 +28,37 @@ type Props = {
const defaultValues: Partial<ICycle> = {
name: "",
description: "",
status: "draft",
start_date: new Date().toString(),
end_date: new Date().toString(),
};
const CreateUpdateCycleModal: React.FC<Props> = ({ isOpen, setIsOpen, data, projectId }) => {
const handleClose = () => {
setIsOpen(false);
const timeout = setTimeout(() => {
reset(defaultValues);
clearTimeout(timeout);
}, 500);
};
const { activeWorkspace } = useUser();
const router = useRouter();
const { workspaceSlug } = router.query;
const {
register,
formState: { errors, isSubmitting },
handleSubmit,
control,
reset,
setError,
} = useForm<ICycle>({
defaultValues,
});
useEffect(() => {
if (data) {
setIsOpen(true);
reset(data);
} else {
reset(defaultValues);
}
}, [data, setIsOpen, reset]);
const onSubmit = async (formData: ICycle) => {
if (!activeWorkspace) return;
if (!workspaceSlug) return;
const payload = {
...formData,
start_date: formData.start_date ? renderDateFormat(formData.start_date) : null,
@ -60,7 +66,7 @@ const CreateUpdateCycleModal: React.FC<Props> = ({ isOpen, setIsOpen, data, proj
};
if (!data) {
await cycleService
.createCycle(activeWorkspace.slug, projectId, payload)
.createCycle(workspaceSlug as string, projectId, payload)
.then((res) => {
mutate<ICycle[]>(CYCLE_LIST(projectId), (prevData) => [res, ...(prevData ?? [])], false);
handleClose();
@ -74,7 +80,7 @@ const CreateUpdateCycleModal: React.FC<Props> = ({ isOpen, setIsOpen, data, proj
});
} else {
await cycleService
.updateCycle(activeWorkspace.slug, projectId, data.id, payload)
.updateCycle(workspaceSlug as string, projectId, data.id, payload)
.then((res) => {
mutate<ICycle[]>(
CYCLE_LIST(projectId),
@ -101,18 +107,14 @@ const CreateUpdateCycleModal: React.FC<Props> = ({ isOpen, setIsOpen, data, proj
}
};
useEffect(() => {
if (data) {
setIsOpen(true);
reset(data);
} else {
reset(defaultValues);
}
}, [data, setIsOpen, reset]);
const handleClose = () => {
setIsOpen(false);
reset(defaultValues);
};
return (
<Transition.Root show={isOpen} as={React.Fragment}>
<Dialog as="div" className="relative z-10" onClose={handleClose}>
<Dialog as="div" className="relative z-30" onClose={handleClose}>
<Transition.Child
as={React.Fragment}
enter="ease-out duration-300"
@ -169,20 +171,29 @@ const CreateUpdateCycleModal: React.FC<Props> = ({ isOpen, setIsOpen, data, proj
/>
</div>
<div>
<Select
id="status"
<h6 className="text-gray-500">Status</h6>
<Controller
name="status"
label="Status"
error={errors.status}
register={register}
validations={{
required: "Status is required",
}}
options={[
{ label: "Draft", value: "draft" },
{ label: "Started", value: "started" },
{ label: "Completed", value: "completed" },
]}
control={control}
render={({ field }) => (
<CustomSelect
{...field}
label={
<span className="capitalize">{field.value ?? "Select Status"}</span>
}
input
>
{[
{ label: "Draft", value: "draft" },
{ label: "Started", value: "started" },
{ label: "Completed", value: "completed" },
].map((item) => (
<CustomSelect.Option key={item.value} value={item.value}>
{item.label}
</CustomSelect.Option>
))}
</CustomSelect>
)}
/>
</div>
<div className="flex gap-x-2">
@ -217,7 +228,7 @@ const CreateUpdateCycleModal: React.FC<Props> = ({ isOpen, setIsOpen, data, proj
</div>
</div>
</div>
<div className="mt-5 sm:mt-6 sm:grid sm:grid-flow-row-dense sm:grid-cols-2 sm:gap-3">
<div className="mt-5 flex justify-end gap-2">
<Button theme="secondary" onClick={handleClose}>
Cancel
</Button>

Some files were not shown because too many files have changed in this diff Show More