Compare commits

...

72 Commits

Author SHA1 Message Date
rahulramesha 32b4019a69 add Layout options while creating a new view 2024-06-19 16:32:31 +05:30
Aaryan Khandelwal 135024a940 [WEB-1397] chore: update space app types (#4861)
* chore: update types

* chore: dummy page actions components

* chore: rename component

* refactor: rearrange declarations
2024-06-18 17:51:52 +05:30
rahulramesha 8500c63205 fix issue pagination cursor logic (#4857) 2024-06-18 17:45:24 +05:30
Prateek Shourya c8736f13ec [WEB-1635] style: fix vertical section tabs highlights. (#4855)
* [WEB-1635] style: fix vertical section tabs highlights.

* fix: highlights in archive tabs.
2024-06-18 16:59:15 +05:30
Prateek Shourya e43b4b3d47 [WEB-1611] style: fixed state column width for consistency. (#4859) 2024-06-18 16:17:51 +05:30
Aaryan Khandelwal 59f0e9fe2c [WEB-1397] fix: UI package logo component (#4858)
* fix: logo component

* chore: add missing anchor observable
2024-06-18 16:16:50 +05:30
guru_sainath 56956d8786 fix: updated the ui inconsistancy in active cycle estimate dropdown (#4860) 2024-06-18 16:15:26 +05:30
guru_sainath cc455b0e76 [WEB-1640] fix: validating the error in estimate delete (#4853)
* fix: validating the error in estimate delete

* fix: moved estimate delete to ee
2024-06-18 16:14:24 +05:30
Aaryan Khandelwal 8705a96220 dev: create a global component for emoji/icon logo (#4851) 2024-06-18 14:48:23 +05:30
Lakhan Baheti 190c85468b [WEB - 1418] chore: events for auth & onboarding (#4576)
* chore: onboarding events added

* chore: updated events

* event payload updated

---------

Co-authored-by: sriram veeraghanta <veeraghanta.sriram@gmail.com>
2024-06-18 14:14:46 +05:30
guru_sainath d3d723cadd [WEB-1632] fix: validating and showing proper alert estimate point has to be taken greater than 0 in create and update (#4850)
* fix: validating and showing proper alert estimate point has to be taken greater than 0 in create and update

* fix: updating the number point validation
2024-06-18 13:31:23 +05:30
Prateek Shourya 6828d33c3f [WEB-1634] dev: update application router to support n-progress. (#4846)
* [WEB-1634] dev: update application router to support n-progress.

* chore: update app router initilization logic.

* fix: lint errors
2024-06-18 11:35:20 +05:30
rahulramesha 10e67144a0 fix the bug that incorrectly updates issue store for array properties during bulk ops (#4849) 2024-06-18 11:34:57 +05:30
Prateek Shourya 0e63128d45 [WEB-1589] chore: fix list group header padding. (#4848) 2024-06-17 20:49:41 +05:30
Aaryan Khandelwal c9cf7cc631 [WEB-1397] refactor: edition specific migration (#4847)
* refactor: edition specific migration

* revert: pagination from space endpoints

* fix: project publish

---------

Co-authored-by: pablohashescobar <nikhilschacko@gmail.com>
2024-06-17 20:09:15 +05:30
Aaryan Khandelwal 413d6d21b4 [WEB-1397] chore: add anchor key to page type (#4844)
* chore: add anchor key to page type

* chore: add anchor key to page type
2024-06-17 20:02:02 +05:30
Bavisetti Narayan 909fe128f1 chore: change one estimate point to another (#4845) 2024-06-17 18:26:49 +05:30
sriram veeraghanta 6a997bb9da fix: workspace help section badge 2024-06-17 18:03:32 +05:30
Prateek Shourya 0dc0a2a8a8 [WEB-1612] chore: add length validation for state name. (#4837)
* [WEB-1612] chore: add length validation for state name.

* style: update error message padding.
2024-06-17 17:03:26 +05:30
rahulramesha e01e736ffe fix bulk issues selection checkboxes in list view (#4842) 2024-06-17 16:53:33 +05:30
Aaryan Khandelwal ae3dcc3dbd [WEB-1397] chore: update project publish types (#4841)
* chore: update project publish types

* chore: added additional entity name

* chore: updated types
2024-06-17 16:51:38 +05:30
Prateek Shourya c4f5093492 [WEB-1630] chore: fix project doesn't exist empty state fluctuation on refresh. (#4835) 2024-06-17 16:50:56 +05:30
Prateek Shourya cf8053825b [WEB-1551] fix: theme mutation when custom theme is applied. (#4838) 2024-06-17 16:49:42 +05:30
Satish Gandham fc3e63f67a Add some new eslint rules and fix the corresponding errors (#4839) 2024-06-17 16:45:35 +05:30
Anmol Singh Bhatia c5cac27026 [WEB-1622] chore: profile setting layout improvement (#4840)
* chore: profile setting content header and wrapper component added

* chore: profile setting content header and wrapper component added

* chore: profile settings layout code refactor
2024-06-17 16:32:36 +05:30
rahulramesha 072e8213f0 fix error while switching grouped by (#4843) 2024-06-17 16:23:17 +05:30
sriram veeraghanta 5eb8e76b3b fix: turbo upgrade 2024-06-17 16:15:14 +05:30
Manish Gupta bd0799f5e7 replaced redis docker images with valkey/valkey (#4836) 2024-06-17 14:39:43 +05:30
Aaryan Khandelwal aba2af9a7c [WEB-1559] chore: updated pages response (#4821)
* dev: fix pages responses

* chore: updated pages response

* fix: search endpoint

* fix: pages delete endpoint

* fix: command k pages response

---------

Co-authored-by: pablohashescobar <nikhilschacko@gmail.com>
2024-06-17 13:56:41 +05:30
Anmol Singh Bhatia 1028ec8735 [WEB-1617] chore: created by implementation (#4831)
* chore: created by implementation

* Fix lint issue

---------

Co-authored-by: Satish Gandham <satish.iitg@gmail.com>
2024-06-17 11:47:19 +05:30
Anmol Singh Bhatia c99579cddc [WEB-1600] chore: issue detail ui enhancement (#4832)
* chore: archived issue header consistency

* chore: restor banner removed from issue detail page

* chore: issue detail quick action component added

* chore: moved sidebar issue quick action to app header
2024-06-15 18:12:18 +05:30
rahulramesha bba10d7073 [WEB-1603] fix: load more issues when issues are deleted (#4830)
* fix load more issues when issues are deleted

* fix build
2024-06-15 11:07:04 +05:30
rahulramesha a8f4d21e8b [WEB-1596] fix: empty groups for issue list and kanban (#4829)
* fix empty groups in kanban and list

* fix build error
2024-06-14 20:46:04 +05:30
Prateek Shourya c5731ce454 fix: duplicate import lint error. (#4827) 2024-06-14 19:27:21 +05:30
guru_sainath 8f091b7d7e [WEB-522] chore: estimate point active cycles pending count and fixed burndown graph total issues (#4825)
* chore: updated active cycles count and graph payload

* chore: updated mobx observer
2024-06-14 19:06:24 +05:30
Nikhil 84236f506b fix: gitlab authentication (#4826) 2024-06-14 18:29:12 +05:30
sriram veeraghanta 5bbb796e5e fix: migration order 2024-06-14 17:59:45 +05:30
Prateek Shourya 59256588db [WEB-1628] style: fix admin app telmetry checkbox on setup page. (#4824) 2024-06-14 17:42:50 +05:30
Nikhil 2a740b9cd9 [WEB - 1604]fix: pagination on many to many fields when grouping and sub grouping (#4818)
* fix: pagination on module grouping

* fix: pagination on grouping with m2m fields
2024-06-14 17:40:33 +05:30
Anmol Singh Bhatia 831a336690 [WEB-1613] chore: material logo loader (#4823)
* chore: material logo loader added

* chore: material logo loader added
2024-06-14 17:37:05 +05:30
Aaryan Khandelwal 244986554c chore: move modal core components to the UI package (#4794)
* chore: move modal core components to the UI package

* fix: build errors
2024-06-14 17:12:39 +05:30
Prateek Shourya 0aca5c7a86 [WEB-1601] fix: archive issues mutation. (#4815) 2024-06-14 17:09:49 +05:30
M. Palanikannan 71c77d30a0 fix: extra indexed db update on mount (reload) causing repeated popups of unsaved changes (#4808)
* docs: update self-host guide link in README (#4704)

found via:

- https://github.com/makeplane/docs/issues/48
- https://github.com/makeplane/plane/pull/3109

* fix: extra indexed db update on mount causing repeated popups on unload

* revert: old stuff

---------

Co-authored-by: jon ⚝ <jon@allmende.io>
2024-06-14 17:01:36 +05:30
Prateek Shourya c5b1d95c76 fix: god mode redireciton without trailing slash. (#4811) 2024-06-14 17:00:35 +05:30
guru_sainath 707c4f9e8d [WEB-522] chore: handled numeric validation on estimate point update in categories (#4819)
* dev: handled numeric validation on estimate point update

* chore: updated number comparision in estimate category type
2024-06-14 16:51:26 +05:30
Prateek Shourya fd9f0fb17c [WEB-1607[ fix: state dropwdown default state. (#4816) 2024-06-14 16:45:06 +05:30
Prateek Shourya d1bfed950a [WEB-1608] fix: deleted project errors (#4820)
* dev: fix project not found error

* [WEB-1608] chore: fix no projects found logic.

---------

Co-authored-by: pablohashescobar <nikhilschacko@gmail.com>
2024-06-14 16:39:25 +05:30
guru_sainath cfbc0cf2e1 chore: ui fix and validation on estimare on active cycles estimate in project cycles (#4822) 2024-06-14 16:38:48 +05:30
sriram veeraghanta 299e220d08 fix: adding constant package. 2024-06-14 15:29:27 +05:30
sriram veeraghanta 64bbe19f1b fix: merging gitlab changes 2024-06-14 15:23:30 +05:30
sriram veeraghanta 92ea5998c5 Merge branch 'preview' of github.com:makeplane/plane into develop 2024-06-14 15:01:51 +05:30
jon ⚝ 99e1963d9b feat: add GitLab OAuth client (#4692) 2024-06-14 14:55:59 +05:30
guru_sainath b3626d815f [WEB-522] chore: estimate restructure and handled error while creating estimates (#4817)
* dev:updated estimate UI

* dev: updated error messages in estimate point create
2024-06-14 13:23:58 +05:30
sriram veeraghanta 9145234a6c fix: core store import fixes 2024-06-14 03:50:25 +05:30
sriram veeraghanta 78d4d981d1 fix: core root store import fixes 2024-06-14 03:37:18 +05:30
sriram veeraghanta c9147e7a57 Merge branch 'develop' of github.com:makeplane/plane into develop 2024-06-13 21:17:17 +05:30
sriram veeraghanta b8ee986636 fix: eslint config and errors 2024-06-13 21:17:06 +05:30
rahulramesha f5f5726e15 fix ee sync error on base issues store (#4812) 2024-06-13 21:15:20 +05:30
sriram veeraghanta a72d095e60 Merge branch 'develop' of github.com:makeplane/plane into develop 2024-06-13 20:30:58 +05:30
sriram veeraghanta 5183f4439b fix: eslint warnings 2024-06-13 20:30:22 +05:30
Aaryan Khandelwal e5d2902a41 chore: add woprkspace pages empty state (#4806) 2024-06-13 17:58:45 +05:30
Satish Gandham 7f97f234d0 Add missing exports (#4807) 2024-06-13 17:41:22 +05:30
Aaryan Khandelwal 645764e53d chore: update page search input (#4805) 2024-06-13 16:56:48 +05:30
rahulramesha d75e33ccf0 fix issue mutation and count (#4804) 2024-06-13 16:55:42 +05:30
Anmol Singh Bhatia ab3a00dd6e chore: admin sidebar improvement (#4802) 2024-06-13 15:52:21 +05:30
guru_sainath 52617baf0e [WEB-522] chore:Estimate structure (#4801)
* dv: seperating constants for ce and ee

* dev: update estimate constants

* dev: updated estimate structure for ce and ee
2024-06-13 15:51:41 +05:30
Anmol Singh Bhatia ee4ad580fc chore: auth email input one password extenction improvement (#4791) 2024-06-13 14:50:36 +05:30
Prateek Shourya d9c8271f35 chore: build error fixes and code cleanup. (#4800)
* chore: add export for store.

* chore: remove pages route group.

* fix: upgrading turbo

---------

Co-authored-by: sriram veeraghanta <veeraghanta.sriram@gmail.com>
2024-06-13 14:49:59 +05:30
Aaryan Khandelwal e79b0c40ec chore: rename page store (#4799) 2024-06-13 13:56:33 +05:30
Prateek Shourya 0b4b092eaa fix: add default export for sidebar. (#4798) 2024-06-13 13:43:51 +05:30
Aaryan Khandelwal 703aac597c chore: create extended root store (#4796)
* chore: create extended root store

* chore: rename core root store
2024-06-13 13:27:13 +05:30
jon ⚝ c24be25024 docs: update self-host guide link in README (#4704)
found via:

- https://github.com/makeplane/docs/issues/48
- https://github.com/makeplane/plane/pull/3109
2024-06-11 02:38:43 +05:30
553 changed files with 4548 additions and 2637 deletions
+1 -1
View File
@@ -48,7 +48,7 @@ Meet [Plane](https://dub.sh/plane-website-readme), an open-source project manage
The easiest way to get started with Plane is by creating a [Plane Cloud](https://app.plane.so) account.
If you would like to self-host Plane, please see our [deployment guide](https://docs.plane.so/self-hosting/overview).
If you would like to self-host Plane, please see our [deployment guide](https://docs.plane.so/docker-compose).
| Installation methods | Docs link |
| -------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------ |
@@ -0,0 +1,59 @@
"use client";
import React from "react";
import { observer } from "mobx-react-lite";
import Link from "next/link";
// icons
import { Settings2 } from "lucide-react";
// types
import { TInstanceAuthenticationMethodKeys } from "@plane/types";
// ui
import { ToggleSwitch, getButtonStyling } from "@plane/ui";
// helpers
import { cn } from "@/helpers/common.helper";
// hooks
import { useInstance } from "@/hooks/store";
type Props = {
disabled: boolean;
updateConfig: (key: TInstanceAuthenticationMethodKeys, value: string) => void;
};
export const GitlabConfiguration: React.FC<Props> = observer((props) => {
const { disabled, updateConfig } = props;
// store
const { formattedConfig } = useInstance();
// derived values
const enableGitlabConfig = formattedConfig?.IS_GITLAB_ENABLED ?? "";
const isGitlabConfigured = !!formattedConfig?.GITLAB_CLIENT_ID && !!formattedConfig?.GITLAB_CLIENT_SECRET;
return (
<>
{isGitlabConfigured ? (
<div className="flex items-center gap-4">
<Link href="/authentication/gitlab" className={cn(getButtonStyling("link-primary", "md"), "font-medium")}>
Edit
</Link>
<ToggleSwitch
value={Boolean(parseInt(enableGitlabConfig))}
onChange={() => {
Boolean(parseInt(enableGitlabConfig)) === true
? updateConfig("IS_GITLAB_ENABLED", "0")
: updateConfig("IS_GITLAB_ENABLED", "1");
}}
size="sm"
disabled={disabled}
/>
</div>
) : (
<Link
href="/authentication/gitlab"
className={cn(getButtonStyling("neutral-primary", "sm"), "text-custom-text-300")}
>
<Settings2 className="h-4 w-4 p-0.5 text-custom-text-300/80" />
Configure
</Link>
)}
</>
);
});
@@ -1,5 +1,6 @@
export * from "./email-config-switch";
export * from "./password-config-switch";
export * from "./authentication-method-card";
export * from "./gitlab-config";
export * from "./github-config";
export * from "./google-config";
+212
View File
@@ -0,0 +1,212 @@
import { FC, useState } from "react";
import isEmpty from "lodash/isEmpty";
import Link from "next/link";
import { useForm } from "react-hook-form";
// types
import { IFormattedInstanceConfiguration, TInstanceGitlabAuthenticationConfigurationKeys } from "@plane/types";
// ui
import { Button, TOAST_TYPE, getButtonStyling, setToast } from "@plane/ui";
// components
import {
ConfirmDiscardModal,
ControllerInput,
CopyField,
TControllerInputFormField,
TCopyField,
} from "@/components/common";
// helpers
import { API_BASE_URL, cn } from "@/helpers/common.helper";
// hooks
import { useInstance } from "@/hooks/store";
type Props = {
config: IFormattedInstanceConfiguration;
};
type GitlabConfigFormValues = Record<TInstanceGitlabAuthenticationConfigurationKeys, string>;
export const InstanceGitlabConfigForm: FC<Props> = (props) => {
const { config } = props;
// states
const [isDiscardChangesModalOpen, setIsDiscardChangesModalOpen] = useState(false);
// store hooks
const { updateInstanceConfigurations } = useInstance();
// form data
const {
handleSubmit,
control,
reset,
formState: { errors, isDirty, isSubmitting },
} = useForm<GitlabConfigFormValues>({
defaultValues: {
GITLAB_HOST: config["GITLAB_HOST"],
GITLAB_CLIENT_ID: config["GITLAB_CLIENT_ID"],
GITLAB_CLIENT_SECRET: config["GITLAB_CLIENT_SECRET"],
},
});
const originURL = !isEmpty(API_BASE_URL) ? API_BASE_URL : typeof window !== "undefined" ? window.location.origin : "";
const GITLAB_FORM_FIELDS: TControllerInputFormField[] = [
{
key: "GITLAB_HOST",
type: "text",
label: "Host",
description: (
<>
This is the <b>GitLab host</b> to use for login, <b>including scheme</b>.
</>
),
placeholder: "https://gitlab.com",
error: Boolean(errors.GITLAB_HOST),
required: true,
},
{
key: "GITLAB_CLIENT_ID",
type: "text",
label: "Application ID",
description: (
<>
Get this from your{" "}
<a
tabIndex={-1}
href="https://docs.gitlab.com/ee/integration/oauth_provider.html"
target="_blank"
className="text-custom-primary-100 hover:underline"
rel="noreferrer"
>
GitLab OAuth application settings
</a>
.
</>
),
placeholder: "c2ef2e7fc4e9d15aa7630f5637d59e8e4a27ff01dceebdb26b0d267b9adcf3c3",
error: Boolean(errors.GITLAB_CLIENT_ID),
required: true,
},
{
key: "GITLAB_CLIENT_SECRET",
type: "password",
label: "Secret",
description: (
<>
The client secret is also found in your{" "}
<a
tabIndex={-1}
href="https://docs.gitlab.com/ee/integration/oauth_provider.html"
target="_blank"
className="text-custom-primary-100 hover:underline"
rel="noreferrer"
>
GitLab OAuth application settings
</a>
.
</>
),
placeholder: "gloas-f79cfa9a03c97f6ffab303177a5a6778a53c61e3914ba093412f68a9298a1b28",
error: Boolean(errors.GITLAB_CLIENT_SECRET),
required: true,
},
];
const GITLAB_SERVICE_FIELD: TCopyField[] = [
{
key: "Callback_URL",
label: "Callback URL",
url: `${originURL}/auth/gitlab/callback/`,
description: (
<>
We will auto-generate this. Paste this into the <b>Redirect URI</b> field of your{" "}
<a
tabIndex={-1}
href="https://docs.gitlab.com/ee/integration/oauth_provider.html"
target="_blank"
className="text-custom-primary-100 hover:underline"
rel="noreferrer"
>
GitLab OAuth application
</a>
.
</>
),
},
];
const onSubmit = async (formData: GitlabConfigFormValues) => {
const payload: Partial<GitlabConfigFormValues> = { ...formData };
await updateInstanceConfigurations(payload)
.then((response = []) => {
setToast({
type: TOAST_TYPE.SUCCESS,
title: "Success",
message: "GitLab Configuration Settings updated successfully",
});
reset({
GITLAB_HOST: response.find((item) => item.key === "GITLAB_HOST")?.value,
GITLAB_CLIENT_ID: response.find((item) => item.key === "GITLAB_CLIENT_ID")?.value,
GITLAB_CLIENT_SECRET: response.find((item) => item.key === "GITLAB_CLIENT_SECRET")?.value,
});
})
.catch((err) => console.error(err));
};
const handleGoBack = (e: React.MouseEvent<HTMLAnchorElement, MouseEvent>) => {
if (isDirty) {
e.preventDefault();
setIsDiscardChangesModalOpen(true);
}
};
return (
<>
<ConfirmDiscardModal
isOpen={isDiscardChangesModalOpen}
onDiscardHref="/authentication"
handleClose={() => setIsDiscardChangesModalOpen(false)}
/>
<div className="flex flex-col gap-8">
<div className="grid grid-cols-2 gap-x-12 gap-y-8 w-full">
<div className="flex flex-col gap-y-4 col-span-2 md:col-span-1">
<div className="pt-2 text-xl font-medium">Configuration</div>
{GITLAB_FORM_FIELDS.map((field) => (
<ControllerInput
key={field.key}
control={control}
type={field.type}
name={field.key}
label={field.label}
description={field.description}
placeholder={field.placeholder}
error={field.error}
required={field.required}
/>
))}
<div className="flex flex-col gap-1 pt-4">
<div className="flex items-center gap-4">
<Button variant="primary" onClick={handleSubmit(onSubmit)} loading={isSubmitting} disabled={!isDirty}>
{isSubmitting ? "Saving..." : "Save changes"}
</Button>
<Link
href="/authentication"
className={cn(getButtonStyling("link-neutral", "md"), "font-medium")}
onClick={handleGoBack}
>
Go back
</Link>
</div>
</div>
</div>
<div className="col-span-2 md:col-span-1">
<div className="flex flex-col gap-y-4 px-6 py-4 my-2 bg-custom-background-80/60 rounded-lg">
<div className="pt-2 text-xl font-medium">Service provider details</div>
{GITLAB_SERVICE_FIELD.map((field) => (
<CopyField key={field.key} label={field.label} url={field.url} description={field.description} />
))}
</div>
</div>
</div>
</div>
</>
);
};
+101
View File
@@ -0,0 +1,101 @@
"use client";
import { useState } from "react";
import { observer } from "mobx-react-lite";
import Image from "next/image";
import useSWR from "swr";
import { Loader, ToggleSwitch, setPromiseToast } from "@plane/ui";
// components
import { PageHeader } from "@/components/core";
// hooks
import { useInstance } from "@/hooks/store";
// icons
import GitlabLogo from "@/public/logos/gitlab-logo.svg";
// local components
import { AuthenticationMethodCard } from "../components";
import { InstanceGitlabConfigForm } from "./form";
const InstanceGitlabAuthenticationPage = observer(() => {
// store
const { fetchInstanceConfigurations, formattedConfig, updateInstanceConfigurations } = useInstance();
// state
const [isSubmitting, setIsSubmitting] = useState<boolean>(false);
// config
const enableGitlabConfig = formattedConfig?.IS_GITLAB_ENABLED ?? "";
useSWR("INSTANCE_CONFIGURATIONS", () => fetchInstanceConfigurations());
const updateConfig = async (key: "IS_GITLAB_ENABLED", value: string) => {
setIsSubmitting(true);
const payload = {
[key]: value,
};
const updateConfigPromise = updateInstanceConfigurations(payload);
setPromiseToast(updateConfigPromise, {
loading: "Saving Configuration...",
success: {
title: "Configuration saved",
message: () => `GitLab authentication is now ${value ? "active" : "disabled"}.`,
},
error: {
title: "Error",
message: () => "Failed to save configuration",
},
});
await updateConfigPromise
.then(() => {
setIsSubmitting(false);
})
.catch((err) => {
console.error(err);
setIsSubmitting(false);
});
};
return (
<>
<PageHeader title="Authentication - God Mode" />
<div className="relative container mx-auto w-full h-full p-4 py-4 space-y-6 flex flex-col">
<div className="border-b border-custom-border-100 mx-4 py-4 space-y-1 flex-shrink-0">
<AuthenticationMethodCard
name="GitLab"
description="Allow members to login or sign up to plane with their GitLab accounts."
icon={<Image src={GitlabLogo} height={24} width={24} alt="GitLab Logo" />}
config={
<ToggleSwitch
value={Boolean(parseInt(enableGitlabConfig))}
onChange={() => {
Boolean(parseInt(enableGitlabConfig)) === true
? updateConfig("IS_GITLAB_ENABLED", "0")
: updateConfig("IS_GITLAB_ENABLED", "1");
}}
size="sm"
disabled={isSubmitting || !formattedConfig}
/>
}
disabled={isSubmitting || !formattedConfig}
withBorder={false}
/>
</div>
<div className="flex-grow overflow-hidden overflow-y-scroll vertical-scrollbar scrollbar-md p-4">
{formattedConfig ? (
<InstanceGitlabConfigForm config={formattedConfig} />
) : (
<Loader className="space-y-8">
<Loader.Item height="50px" width="25%" />
<Loader.Item height="50px" />
<Loader.Item height="50px" />
<Loader.Item height="50px" />
<Loader.Item height="50px" width="50%" />
</Loader>
)}
</div>
</div>
</>
);
});
export default InstanceGitlabAuthenticationPage;
+9
View File
@@ -17,12 +17,14 @@ import { useInstance } from "@/hooks/store";
// images
import githubLightModeImage from "@/public/logos/github-black.png";
import githubDarkModeImage from "@/public/logos/github-white.png";
import GitlabLogo from "@/public/logos/gitlab-logo.svg";
import GoogleLogo from "@/public/logos/google-logo.svg";
// local components
import {
AuthenticationMethodCard,
EmailCodesConfiguration,
PasswordLoginConfiguration,
GitlabConfiguration,
GithubConfiguration,
GoogleConfiguration,
} from "./components";
@@ -116,6 +118,13 @@ const InstanceAuthenticationPage = observer(() => {
),
config: <GithubConfiguration disabled={isSubmitting} updateConfig={updateConfig} />,
},
{
key: "gitlab",
name: "GitLab",
description: "Allow members to login or sign up to plane with their GitLab accounts.",
icon: <Image src={GitlabLogo} height={20} width={20} alt="GitLab Logo" />,
config: <GitlabConfiguration disabled={isSubmitting} updateConfig={updateConfig} />,
},
];
return (
+4 -4
View File
@@ -41,10 +41,10 @@ export const InstanceSidebar: FC<IInstanceSidebar> = observer(() => {
<div
className={`inset-y-0 z-20 flex h-full flex-shrink-0 flex-grow-0 flex-col border-r border-custom-sidebar-border-200 bg-custom-sidebar-background-100 duration-300
fixed md:relative
${isSidebarCollapsed ? "-ml-[280px]" : ""}
sm:${isSidebarCollapsed ? "-ml-[280px]" : ""}
md:ml-0 ${isSidebarCollapsed ? "w-[80px]" : "w-[280px]"}
lg:ml-0 ${isSidebarCollapsed ? "w-[80px]" : "w-[280px]"}
${isSidebarCollapsed ? "-ml-[250px]" : ""}
sm:${isSidebarCollapsed ? "-ml-[250px]" : ""}
md:ml-0 ${isSidebarCollapsed ? "w-[70px]" : "w-[250px]"}
lg:ml-0 ${isSidebarCollapsed ? "w-[70px]" : "w-[250px]"}
`}
>
<div ref={ref} className="flex h-full w-full flex-1 flex-col">
+2
View File
@@ -31,6 +31,8 @@ export const InstanceHeader: FC = observer(() => {
return "Google";
case "github":
return "Github";
case "gitlab":
return "GitLab";
default:
return pathName.toUpperCase();
}
+2
View File
@@ -319,6 +319,8 @@ export const InstanceSetupForm: FC = (props) => {
<div className="relative flex items-center pt-2 gap-2">
<div>
<Checkbox
className="w-4 h-4"
iconClassName="w-3 h-3"
id="is_telemetry_enabled"
onChange={() => handleFormChange("is_telemetry_enabled", !formData.is_telemetry_enabled)}
checked={formData.is_telemetry_enabled}
+1
View File
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="80 80 220 220"><defs><style>.cls-1{fill:#e24329;}.cls-2{fill:#fc6d26;}.cls-3{fill:#fca326;}</style></defs><g id="LOGO"><path class="cls-1" d="M282.83,170.73l-.27-.69-26.14-68.22a6.81,6.81,0,0,0-2.69-3.24,7,7,0,0,0-8,.43,7,7,0,0,0-2.32,3.52l-17.65,54H154.29l-17.65-54A6.86,6.86,0,0,0,134.32,99a7,7,0,0,0-8-.43,6.87,6.87,0,0,0-2.69,3.24L97.44,170l-.26.69a48.54,48.54,0,0,0,16.1,56.1l.09.07.24.17,39.82,29.82,19.7,14.91,12,9.06a8.07,8.07,0,0,0,9.76,0l12-9.06,19.7-14.91,40.06-30,.1-.08A48.56,48.56,0,0,0,282.83,170.73Z"/><path class="cls-2" d="M282.83,170.73l-.27-.69a88.3,88.3,0,0,0-35.15,15.8L190,229.25c19.55,14.79,36.57,27.64,36.57,27.64l40.06-30,.1-.08A48.56,48.56,0,0,0,282.83,170.73Z"/><path class="cls-3" d="M153.43,256.89l19.7,14.91,12,9.06a8.07,8.07,0,0,0,9.76,0l12-9.06,19.7-14.91S209.55,244,190,229.25C170.45,244,153.43,256.89,153.43,256.89Z"/><path class="cls-2" d="M132.58,185.84A88.19,88.19,0,0,0,97.44,170l-.26.69a48.54,48.54,0,0,0,16.1,56.1l.09.07.24.17,39.82,29.82s17-12.85,36.57-27.64Z"/></g></svg>

After

Width:  |  Height:  |  Size: 1.0 KiB

View File
View File
+14 -7
View File
@@ -20,7 +20,15 @@ class PageSerializer(BaseSerializer):
write_only=True,
required=False,
)
project = serializers.UUIDField(read_only=True)
# Many to many
label_ids = serializers.ListField(
child=serializers.UUIDField(),
required=False,
)
project_ids = serializers.ListField(
child=serializers.UUIDField(),
required=False,
)
class Meta:
model = Page
@@ -42,18 +50,14 @@ class PageSerializer(BaseSerializer):
"updated_by",
"view_props",
"logo_props",
"project",
"label_ids",
"project_ids",
]
read_only_fields = [
"workspace",
"owned_by",
]
def to_representation(self, instance):
data = super().to_representation(instance)
data["labels"] = [str(label.id) for label in instance.labels.all()]
return data
def create(self, validated_data):
labels = validated_data.pop("labels", None)
project_id = self.context["project_id"]
@@ -63,6 +67,7 @@ class PageSerializer(BaseSerializer):
# Get the workspace id from the project
project = Project.objects.get(pk=project_id)
# Create the page
page = Page.objects.create(
**validated_data,
description_html=description_html,
@@ -70,6 +75,7 @@ class PageSerializer(BaseSerializer):
workspace_id=project.workspace_id,
)
# Create the project page
ProjectPage.objects.create(
workspace_id=page.workspace_id,
project_id=project_id,
@@ -78,6 +84,7 @@ class PageSerializer(BaseSerializer):
updated_by_id=page.updated_by_id,
)
# Create page labels
if labels is not None:
PageLabel.objects.bulk_create(
[
+2 -1
View File
@@ -180,7 +180,8 @@ from .page.base import (
PagesDescriptionViewSet,
)
from .search import GlobalSearchEndpoint, IssueSearchEndpoint
from .search.base import GlobalSearchEndpoint
from .search.issue import IssueSearchEndpoint
from .external.base import (
+18 -9
View File
@@ -62,12 +62,15 @@ class BulkEstimatePointEndpoint(BaseViewSet):
path="/api/workspaces/:slug/estimates/", url_params=True, user=False
)
def create(self, request, slug, project_id):
estimate = request.data.get('estimate')
estimate = request.data.get("estimate")
estimate_name = estimate.get("name", generate_random_name())
estimate_type = estimate.get("type", 'categories')
estimate_type = estimate.get("type", "categories")
last_used = estimate.get("last_used", False)
estimate = Estimate.objects.create(
name=estimate_name, project_id=project_id, last_used=last_used, type=estimate_type
name=estimate_name,
project_id=project_id,
last_used=last_used,
type=estimate_type,
)
estimate_points = request.data.get("estimate_points", [])
@@ -125,8 +128,12 @@ class BulkEstimatePointEndpoint(BaseViewSet):
estimate = Estimate.objects.get(pk=estimate_id)
if request.data.get("estimate"):
estimate.name = request.data.get("estimate").get("name", estimate.name)
estimate.type = request.data.get("estimate").get("type", estimate.type)
estimate.name = request.data.get("estimate").get(
"name", estimate.name
)
estimate.type = request.data.get("estimate").get(
"type", estimate.type
)
estimate.save()
estimate_points_data = request.data.get("estimate_points", [])
@@ -204,7 +211,9 @@ class EstimatePointEndpoint(BaseViewSet):
serializer = EstimatePointSerializer(estimate_point).data
return Response(serializer, status=status.HTTP_200_OK)
def partial_update(self, request, slug, project_id, estimate_id, estimate_point_id):
def partial_update(
self, request, slug, project_id, estimate_id, estimate_point_id
):
# TODO: add a key validation if the same key already exists
estimate_point = EstimatePoint.objects.get(
pk=estimate_point_id,
@@ -225,7 +234,7 @@ class EstimatePointEndpoint(BaseViewSet):
def destroy(
self, request, slug, project_id, estimate_id, estimate_point_id
):
new_estimate_id = request.GET.get("new_estimate_id", None)
new_estimate_id = request.data.get("new_estimate_id", None)
estimate_points = EstimatePoint.objects.filter(
estimate_id=estimate_id,
project_id=project_id,
@@ -236,8 +245,8 @@ class EstimatePointEndpoint(BaseViewSet):
_ = Issue.objects.filter(
project_id=project_id,
workspace__slug=slug,
estimate_id=estimate_point_id,
).update(estimate_id=new_estimate_id)
estimate_point_id=estimate_point_id,
).update(estimate_point_id=new_estimate_id)
# delete the estimate point
old_estimate_point = EstimatePoint.objects.filter(
+33 -8
View File
@@ -6,10 +6,13 @@ from django.core.serializers.json import DjangoJSONEncoder
# Django imports
from django.db import connection
from django.db.models import Exists, OuterRef, Q, Subquery
from django.db.models import Exists, OuterRef, Q, Value, UUIDField
from django.utils.decorators import method_decorator
from django.views.decorators.gzip import gzip_page
from django.http import StreamingHttpResponse
from django.contrib.postgres.aggregates import ArrayAgg
from django.contrib.postgres.fields import ArrayField
from django.db.models.functions import Coalesce
# Third party imports
from rest_framework import status
@@ -70,9 +73,6 @@ class PageViewSet(BaseViewSet):
entity_identifier=OuterRef("pk"),
workspace__slug=self.kwargs.get("slug"),
)
project_subquery = ProjectPage.objects.filter(
page_id=OuterRef("id"), project_id=self.kwargs.get("project_id")
).values_list("project_id", flat=True)[:1]
return self.filter_queryset(
super()
.get_queryset()
@@ -91,8 +91,33 @@ class PageViewSet(BaseViewSet):
.order_by(self.request.GET.get("order_by", "-created_at"))
.prefetch_related("labels")
.order_by("-is_favorite", "-created_at")
.annotate(project=Subquery(project_subquery))
.filter(project=self.kwargs.get("project_id"))
.annotate(
project=Exists(
ProjectPage.objects.filter(
page_id=OuterRef("id"),
project_id=self.kwargs.get("project_id"),
)
)
)
.annotate(
label_ids=Coalesce(
ArrayAgg(
"page_labels__label_id",
distinct=True,
filter=~Q(page_labels__label_id__isnull=True),
),
Value([], output_field=ArrayField(UUIDField())),
),
project_ids=Coalesce(
ArrayAgg(
"projects__id",
distinct=True,
filter=~Q(projects__id=True),
),
Value([], output_field=ArrayField(UUIDField())),
),
)
.filter(project=True)
.distinct()
)
@@ -112,7 +137,7 @@ class PageViewSet(BaseViewSet):
serializer.save()
# capture the page transaction
page_transaction.delay(request.data, None, serializer.data["id"])
page = Page.objects.get(pk=serializer.data["id"])
page = self.get_queryset().get(pk=serializer.data["id"])
serializer = PageDetailSerializer(page)
return Response(serializer.data, status=status.HTTP_201_CREATED)
return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST)
@@ -302,7 +327,7 @@ class PageViewSet(BaseViewSet):
# remove parent from all the children
_ = Page.objects.filter(
parent_id=pk, project_id=project_id, workspace__slug=slug
parent_id=pk, projects__id=project_id, workspace__slug=slug
).update(parent=None)
page.delete()
@@ -240,6 +240,12 @@ class ProjectViewSet(BaseViewSet):
)
).first()
if project is None:
return Response(
{"error": "Project does not exist"},
status=status.HTTP_404_NOT_FOUND,
)
serializer = ProjectListSerializer(project)
return Response(serializer.data, status=status.HTTP_200_OK)
@@ -2,14 +2,17 @@
import re
# Django imports
from django.db.models import Q
from django.db.models import Q, OuterRef, Subquery, Value, UUIDField, CharField
from django.contrib.postgres.aggregates import ArrayAgg
from django.contrib.postgres.fields import ArrayField
from django.db.models.functions import Coalesce
# Third party imports
from rest_framework import status
from rest_framework.response import Response
# Module imports
from .base import BaseAPIView
from plane.app.views.base import BaseAPIView
from plane.db.models import (
Workspace,
Project,
@@ -18,8 +21,8 @@ from plane.db.models import (
Module,
Page,
IssueView,
ProjectPage,
)
from plane.utils.issue_search import search_issues
class GlobalSearchEndpoint(BaseAPIView):
@@ -145,22 +148,51 @@ class GlobalSearchEndpoint(BaseAPIView):
for field in fields:
q |= Q(**{f"{field}__icontains": query})
pages = Page.objects.filter(
q,
projects__project_projectmember__member=self.request.user,
projects__project_projectmember__is_active=True,
projects__archived_at__isnull=True,
workspace__slug=slug,
pages = (
Page.objects.filter(
q,
projects__project_projectmember__member=self.request.user,
projects__project_projectmember__is_active=True,
projects__archived_at__isnull=True,
workspace__slug=slug,
)
.annotate(
project_ids=Coalesce(
ArrayAgg(
"projects__id",
distinct=True,
filter=~Q(projects__id=True),
),
Value([], output_field=ArrayField(UUIDField())),
),
)
.annotate(
project_identifiers=Coalesce(
ArrayAgg(
"projects__identifier",
distinct=True,
filter=~Q(projects__id=True),
),
Value([], output_field=ArrayField(CharField())),
),
)
)
if workspace_search == "false" and project_id:
pages = pages.filter(project_id=project_id)
project_subquery = ProjectPage.objects.filter(
page_id=OuterRef("id"),
project_id=project_id,
).values_list("project_id", flat=True)[:1]
pages = pages.annotate(
project_id=Subquery(project_subquery)
).filter(project_id=project_id)
return pages.distinct().values(
"name",
"id",
"project_id",
"project__identifier",
"project_ids",
"project_identifiers",
"workspace__slug",
)
@@ -228,76 +260,3 @@ class GlobalSearchEndpoint(BaseAPIView):
func = MODELS_MAPPER.get(model, None)
results[model] = func(query, slug, project_id, workspace_search)
return Response({"results": results}, status=status.HTTP_200_OK)
class IssueSearchEndpoint(BaseAPIView):
def get(self, request, slug, project_id):
query = request.query_params.get("search", False)
workspace_search = request.query_params.get(
"workspace_search", "false"
)
parent = request.query_params.get("parent", "false")
issue_relation = request.query_params.get("issue_relation", "false")
cycle = request.query_params.get("cycle", "false")
module = request.query_params.get("module", False)
sub_issue = request.query_params.get("sub_issue", "false")
target_date = request.query_params.get("target_date", True)
issue_id = request.query_params.get("issue_id", False)
issues = Issue.issue_objects.filter(
workspace__slug=slug,
project__project_projectmember__member=self.request.user,
project__project_projectmember__is_active=True,
project__archived_at__isnull=True,
)
if workspace_search == "false":
issues = issues.filter(project_id=project_id)
if query:
issues = search_issues(query, issues)
if parent == "true" and issue_id:
issue = Issue.issue_objects.get(pk=issue_id)
issues = issues.filter(
~Q(pk=issue_id), ~Q(pk=issue.parent_id), ~Q(parent_id=issue_id)
)
if issue_relation == "true" and issue_id:
issue = Issue.issue_objects.get(pk=issue_id)
issues = issues.filter(
~Q(pk=issue_id),
~Q(issue_related__issue=issue),
~Q(issue_relation__related_issue=issue),
)
if sub_issue == "true" and issue_id:
issue = Issue.issue_objects.get(pk=issue_id)
issues = issues.filter(~Q(pk=issue_id), parent__isnull=True)
if issue.parent:
issues = issues.filter(~Q(pk=issue.parent_id))
if cycle == "true":
issues = issues.exclude(issue_cycle__isnull=False)
if module:
issues = issues.exclude(issue_module__module=module)
if target_date == "none":
issues = issues.filter(target_date__isnull=True)
return Response(
issues.values(
"name",
"id",
"start_date",
"sequence_id",
"project__name",
"project__identifier",
"project_id",
"workspace__slug",
"state__name",
"state__group",
"state__color",
),
status=status.HTTP_200_OK,
)
+95
View File
@@ -0,0 +1,95 @@
# Python imports
import re
# Django imports
from django.db.models import Q
# Third party imports
from rest_framework import status
from rest_framework.response import Response
# Module imports
from .base import BaseAPIView
from plane.db.models import (
Workspace,
Project,
Issue,
Cycle,
Module,
Page,
IssueView,
)
from plane.utils.issue_search import search_issues
class IssueSearchEndpoint(BaseAPIView):
def get(self, request, slug, project_id):
query = request.query_params.get("search", False)
workspace_search = request.query_params.get(
"workspace_search", "false"
)
parent = request.query_params.get("parent", "false")
issue_relation = request.query_params.get("issue_relation", "false")
cycle = request.query_params.get("cycle", "false")
module = request.query_params.get("module", False)
sub_issue = request.query_params.get("sub_issue", "false")
target_date = request.query_params.get("target_date", True)
issue_id = request.query_params.get("issue_id", False)
issues = Issue.issue_objects.filter(
workspace__slug=slug,
project__project_projectmember__member=self.request.user,
project__project_projectmember__is_active=True,
project__archived_at__isnull=True,
)
if workspace_search == "false":
issues = issues.filter(project_id=project_id)
if query:
issues = search_issues(query, issues)
if parent == "true" and issue_id:
issue = Issue.issue_objects.get(pk=issue_id)
issues = issues.filter(
~Q(pk=issue_id), ~Q(pk=issue.parent_id), ~Q(parent_id=issue_id)
)
if issue_relation == "true" and issue_id:
issue = Issue.issue_objects.get(pk=issue_id)
issues = issues.filter(
~Q(pk=issue_id),
~Q(issue_related__issue=issue),
~Q(issue_relation__related_issue=issue),
)
if sub_issue == "true" and issue_id:
issue = Issue.issue_objects.get(pk=issue_id)
issues = issues.filter(~Q(pk=issue_id), parent__isnull=True)
if issue.parent:
issues = issues.filter(~Q(pk=issue.parent_id))
if cycle == "true":
issues = issues.exclude(issue_cycle__isnull=False)
if module:
issues = issues.exclude(issue_module__module=module)
if target_date == "none":
issues = issues.filter(target_date__isnull=True)
return Response(
issues.values(
"name",
"id",
"start_date",
"sequence_id",
"project__name",
"project__identifier",
"project_id",
"workspace__slug",
"state__name",
"state__group",
"state__color",
),
status=status.HTTP_200_OK,
)
@@ -33,10 +33,13 @@ AUTHENTICATION_ERROR_CODES = {
"EMAIL_CODE_ATTEMPT_EXHAUSTED_SIGN_IN": 5100,
"EMAIL_CODE_ATTEMPT_EXHAUSTED_SIGN_UP": 5102,
# Oauth
"OAUTH_NOT_CONFIGURED": 5104,
"GOOGLE_NOT_CONFIGURED": 5105,
"GITHUB_NOT_CONFIGURED": 5110,
"GITLAB_NOT_CONFIGURED": 5111,
"GOOGLE_OAUTH_PROVIDER_ERROR": 5115,
"GITHUB_OAUTH_PROVIDER_ERROR": 5120,
"GITLAB_OAUTH_PROVIDER_ERROR": 5121,
# Reset Password
"INVALID_PASSWORD_TOKEN": 5125,
"EXPIRED_PASSWORD_TOKEN": 5130,
@@ -62,11 +62,7 @@ class OauthAdapter(Adapter):
response.raise_for_status()
return response.json()
except requests.RequestException:
code = (
"GOOGLE_OAUTH_PROVIDER_ERROR"
if self.provider == "google"
else "GITHUB_OAUTH_PROVIDER_ERROR"
)
code = self._provider_error_code()
raise AuthenticationException(
error_code=AUTHENTICATION_ERROR_CODES[code],
error_message=str(code),
@@ -83,8 +79,12 @@ class OauthAdapter(Adapter):
except requests.RequestException:
if self.provider == "google":
code = "GOOGLE_OAUTH_PROVIDER_ERROR"
if self.provider == "github":
elif self.provider == "github":
code = "GITHUB_OAUTH_PROVIDER_ERROR"
elif self.provider == "gitlab":
code = "GITLAB_OAUTH_PROVIDER_ERROR"
else:
code = "OAUTH_NOT_CONFIGURED"
raise AuthenticationException(
error_code=AUTHENTICATION_ERROR_CODES[code],
@@ -0,0 +1,132 @@
# Python imports
import os
from datetime import datetime
from urllib.parse import urlencode
import pytz
# Module imports
from plane.authentication.adapter.oauth import OauthAdapter
from plane.license.utils.instance_value import get_configuration_value
from plane.authentication.adapter.error import (
AuthenticationException,
AUTHENTICATION_ERROR_CODES,
)
class GitLabOAuthProvider(OauthAdapter):
provider = "gitlab"
scope = "read_user"
def __init__(self, request, code=None, state=None, callback=None):
GITLAB_CLIENT_ID, GITLAB_CLIENT_SECRET, GITLAB_HOST = (
get_configuration_value(
[
{
"key": "GITLAB_CLIENT_ID",
"default": os.environ.get("GITLAB_CLIENT_ID"),
},
{
"key": "GITLAB_CLIENT_SECRET",
"default": os.environ.get("GITLAB_CLIENT_SECRET"),
},
{
"key": "GITLAB_HOST",
"default": os.environ.get(
"GITLAB_HOST", "https://gitlab.com"
),
},
]
)
)
self.host = GITLAB_HOST
self.token_url = f"{self.host}/oauth/token"
self.userinfo_url = f"{self.host}/api/v4/user"
if not (GITLAB_CLIENT_ID and GITLAB_CLIENT_SECRET and GITLAB_HOST):
raise AuthenticationException(
error_code=AUTHENTICATION_ERROR_CODES["GITLAB_NOT_CONFIGURED"],
error_message="GITLAB_NOT_CONFIGURED",
)
client_id = GITLAB_CLIENT_ID
client_secret = GITLAB_CLIENT_SECRET
redirect_uri = f"""{"https" if request.is_secure() else "http"}://{request.get_host()}/auth/gitlab/callback/"""
url_params = {
"client_id": client_id,
"redirect_uri": redirect_uri,
"response_type": "code",
"scope": self.scope,
"state": state,
}
auth_url = f"{self.host}/oauth/authorize?{urlencode(url_params)}"
super().__init__(
request,
self.provider,
client_id,
self.scope,
redirect_uri,
auth_url,
self.token_url,
self.userinfo_url,
client_secret,
code,
callback=callback,
)
def set_token_data(self):
data = {
"client_id": self.client_id,
"client_secret": self.client_secret,
"code": self.code,
"redirect_uri": self.redirect_uri,
"grant_type": "authorization_code",
}
token_response = self.get_user_token(
data=data, headers={"Accept": "application/json"}
)
super().set_token_data(
{
"access_token": token_response.get("access_token"),
"refresh_token": token_response.get("refresh_token", None),
"access_token_expired_at": (
datetime.fromtimestamp(
token_response.get("created_at")
+ token_response.get("expires_in"),
tz=pytz.utc,
)
if token_response.get("expires_in")
else None
),
"refresh_token_expired_at": (
datetime.fromtimestamp(
token_response.get("refresh_token_expired_at"),
tz=pytz.utc,
)
if token_response.get("refresh_token_expired_at")
else None
),
"id_token": token_response.get("id_token", ""),
}
)
def set_user_data(self):
user_info_response = self.get_user_response()
email = user_info_response.get("email")
super().set_user_data(
{
"email": email,
"user": {
"provider_id": user_info_response.get("id"),
"email": email,
"avatar": user_info_response.get("avatar_url"),
"first_name": user_info_response.get("name"),
"last_name": user_info_response.get("family_name"),
"is_password_autoset": True,
},
}
)
+25
View File
@@ -8,6 +8,8 @@ from .views import (
ChangePasswordEndpoint,
# App
EmailCheckEndpoint,
GitLabCallbackEndpoint,
GitLabOauthInitiateEndpoint,
GitHubCallbackEndpoint,
GitHubOauthInitiateEndpoint,
GoogleCallbackEndpoint,
@@ -22,6 +24,8 @@ from .views import (
ResetPasswordSpaceEndpoint,
# Space
EmailCheckSpaceEndpoint,
GitLabCallbackSpaceEndpoint,
GitLabOauthInitiateSpaceEndpoint,
GitHubCallbackSpaceEndpoint,
GitHubOauthInitiateSpaceEndpoint,
GoogleCallbackSpaceEndpoint,
@@ -151,6 +155,27 @@ urlpatterns = [
GitHubCallbackSpaceEndpoint.as_view(),
name="github-callback",
),
## Gitlab Oauth
path(
"gitlab/",
GitLabOauthInitiateEndpoint.as_view(),
name="gitlab-initiate",
),
path(
"gitlab/callback/",
GitLabCallbackEndpoint.as_view(),
name="gitlab-callback",
),
path(
"spaces/gitlab/",
GitLabOauthInitiateSpaceEndpoint.as_view(),
name="gitlab-initiate",
),
path(
"spaces/gitlab/callback/",
GitLabCallbackSpaceEndpoint.as_view(),
name="gitlab-callback",
),
# Email Check
path(
"email-check/",
@@ -14,6 +14,10 @@ from .app.github import (
GitHubCallbackEndpoint,
GitHubOauthInitiateEndpoint,
)
from .app.gitlab import (
GitLabCallbackEndpoint,
GitLabOauthInitiateEndpoint,
)
from .app.google import (
GoogleCallbackEndpoint,
GoogleOauthInitiateEndpoint,
@@ -34,6 +38,11 @@ from .space.github import (
GitHubOauthInitiateSpaceEndpoint,
)
from .space.gitlab import (
GitLabCallbackSpaceEndpoint,
GitLabOauthInitiateSpaceEndpoint,
)
from .space.google import (
GoogleCallbackSpaceEndpoint,
GoogleOauthInitiateSpaceEndpoint,
@@ -0,0 +1,131 @@
import uuid
from urllib.parse import urlencode, urljoin
# Django import
from django.http import HttpResponseRedirect
from django.views import View
# Module imports
from plane.authentication.provider.oauth.gitlab import GitLabOAuthProvider
from plane.authentication.utils.login import user_login
from plane.authentication.utils.redirection_path import get_redirection_path
from plane.authentication.utils.user_auth_workflow import (
post_user_auth_workflow,
)
from plane.license.models import Instance
from plane.authentication.utils.host import base_host
from plane.authentication.adapter.error import (
AuthenticationException,
AUTHENTICATION_ERROR_CODES,
)
class GitLabOauthInitiateEndpoint(View):
def get(self, request):
# Get host and next path
request.session["host"] = base_host(request=request, is_app=True)
next_path = request.GET.get("next_path")
if next_path:
request.session["next_path"] = str(next_path)
# Check instance configuration
instance = Instance.objects.first()
if instance is None or not instance.is_setup_done:
exc = AuthenticationException(
error_code=AUTHENTICATION_ERROR_CODES[
"INSTANCE_NOT_CONFIGURED"
],
error_message="INSTANCE_NOT_CONFIGURED",
)
params = exc.get_error_dict()
if next_path:
params["next_path"] = str(next_path)
url = urljoin(
base_host(request=request, is_app=True),
"?" + urlencode(params),
)
return HttpResponseRedirect(url)
try:
state = uuid.uuid4().hex
provider = GitLabOAuthProvider(request=request, state=state)
request.session["state"] = state
auth_url = provider.get_auth_url()
return HttpResponseRedirect(auth_url)
except AuthenticationException as e:
params = e.get_error_dict()
if next_path:
params["next_path"] = str(next_path)
url = urljoin(
base_host(request=request, is_app=True),
"?" + urlencode(params),
)
return HttpResponseRedirect(url)
class GitLabCallbackEndpoint(View):
def get(self, request):
code = request.GET.get("code")
state = request.GET.get("state")
base_host = request.session.get("host")
next_path = request.session.get("next_path")
if state != request.session.get("state", ""):
exc = AuthenticationException(
error_code=AUTHENTICATION_ERROR_CODES[
"GITLAB_OAUTH_PROVIDER_ERROR"
],
error_message="GITLAB_OAUTH_PROVIDER_ERROR",
)
params = exc.get_error_dict()
if next_path:
params["next_path"] = str(next_path)
url = urljoin(
base_host,
"?" + urlencode(params),
)
return HttpResponseRedirect(url)
if not code:
exc = AuthenticationException(
error_code=AUTHENTICATION_ERROR_CODES[
"GITLAB_OAUTH_PROVIDER_ERROR"
],
error_message="GITLAB_OAUTH_PROVIDER_ERROR",
)
params = exc.get_error_dict()
if next_path:
params["next_path"] = str(next_path)
url = urljoin(
base_host,
"?" + urlencode(params),
)
return HttpResponseRedirect(url)
try:
provider = GitLabOAuthProvider(
request=request,
code=code,
callback=post_user_auth_workflow,
)
user = provider.authenticate()
# Login the user and record his device info
user_login(request=request, user=user, is_app=True)
# Get the redirection path
if next_path:
path = next_path
else:
path = get_redirection_path(user=user)
# redirect to referer path
url = urljoin(base_host, path)
return HttpResponseRedirect(url)
except AuthenticationException as e:
params = e.get_error_dict()
if next_path:
params["next_path"] = str(next_path)
url = urljoin(
base_host,
"?" + urlencode(params),
)
return HttpResponseRedirect(url)
@@ -0,0 +1,109 @@
# Python imports
import uuid
from urllib.parse import urlencode
# Django import
from django.http import HttpResponseRedirect
from django.views import View
# Module imports
from plane.authentication.provider.oauth.gitlab import GitLabOAuthProvider
from plane.authentication.utils.login import user_login
from plane.license.models import Instance
from plane.authentication.utils.host import base_host
from plane.authentication.adapter.error import (
AUTHENTICATION_ERROR_CODES,
AuthenticationException,
)
class GitLabOauthInitiateSpaceEndpoint(View):
def get(self, request):
# Get host and next path
request.session["host"] = base_host(request=request, is_space=True)
next_path = request.GET.get("next_path")
if next_path:
request.session["next_path"] = str(next_path)
# Check instance configuration
instance = Instance.objects.first()
if instance is None or not instance.is_setup_done:
exc = AuthenticationException(
error_code=AUTHENTICATION_ERROR_CODES[
"INSTANCE_NOT_CONFIGURED"
],
error_message="INSTANCE_NOT_CONFIGURED",
)
params = exc.get_error_dict()
if next_path:
params["next_path"] = str(next_path)
url = f"{base_host(request=request, is_space=True)}?{urlencode(params)}"
return HttpResponseRedirect(url)
try:
state = uuid.uuid4().hex
provider = GitLabOAuthProvider(request=request, state=state)
request.session["state"] = state
auth_url = provider.get_auth_url()
return HttpResponseRedirect(auth_url)
except AuthenticationException as e:
params = e.get_error_dict()
if next_path:
params["next_path"] = str(next_path)
url = f"{base_host(request=request, is_space=True)}?{urlencode(params)}"
return HttpResponseRedirect(url)
class GitLabCallbackSpaceEndpoint(View):
def get(self, request):
code = request.GET.get("code")
state = request.GET.get("state")
base_host = request.session.get("host")
next_path = request.session.get("next_path")
if state != request.session.get("state", ""):
exc = AuthenticationException(
error_code=AUTHENTICATION_ERROR_CODES[
"GITLAB_OAUTH_PROVIDER_ERROR"
],
error_message="GITLAB_OAUTH_PROVIDER_ERROR",
)
params = exc.get_error_dict()
if next_path:
params["next_path"] = str(next_path)
url = f"{base_host(request=request, is_space=True)}?{urlencode(params)}"
return HttpResponseRedirect(url)
if not code:
exc = AuthenticationException(
error_code=AUTHENTICATION_ERROR_CODES[
"GITLAB_OAUTH_PROVIDER_ERROR"
],
error_message="GITLAB_OAUTH_PROVIDER_ERROR",
)
params = exc.get_error_dict()
if next_path:
params["next_path"] = str(next_path)
url = f"{base_host(request=request, is_space=True)}?{urlencode(params)}"
return HttpResponseRedirect(url)
try:
provider = GitLabOAuthProvider(
request=request,
code=code,
)
user = provider.authenticate()
# Login the user and record his device info
user_login(request=request, user=user, is_space=True)
# Process workspace and project invitations
# redirect to referer path
url = f"{base_host(request=request, is_space=True)}{str(next_path) if next_path else ''}"
return HttpResponseRedirect(url)
except AuthenticationException as e:
params = e.get_error_dict()
if next_path:
params["next_path"] = str(next_path)
url = f"{base_host(request=request, is_space=True)}?{urlencode(params)}"
return HttpResponseRedirect(url)
@@ -0,0 +1,23 @@
# Generated by Django 4.2.11 on 2024-06-03 17:16
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('db', '0068_remove_pagelabel_project_remove_pagelog_project_and_more'),
]
operations = [
migrations.AlterField(
model_name='account',
name='provider',
field=models.CharField(choices=[('google', 'Google'), ('github', 'Github'), ('gitlab', 'GitLab')]),
),
migrations.AlterField(
model_name='socialloginconnection',
name='medium',
field=models.CharField(choices=[('Google', 'google'), ('Github', 'github'), ('GitLab', 'gitlab'), ('Jira', 'jira')], default=None, max_length=20),
),
]
@@ -10,7 +10,7 @@ from .base import BaseModel
class SocialLoginConnection(BaseModel):
medium = models.CharField(
max_length=20,
choices=(("Google", "google"), ("Github", "github"), ("Jira", "jira")),
choices=(("Google", "google"), ("Github", "github"), ("GitLab", "gitlab"), ("Jira", "jira")),
default=None,
)
last_login_at = models.DateTimeField(default=timezone.now, null=True)
+1 -1
View File
@@ -182,7 +182,7 @@ class Account(TimeAuditModel):
)
provider_account_id = models.CharField(max_length=255)
provider = models.CharField(
choices=(("google", "Google"), ("github", "Github")),
choices=(("google", "Google"), ("github", "Github"), ("gitlab", "GitLab")),
)
access_token = models.TextField()
access_token_expired_at = models.DateTimeField(null=True)
@@ -54,6 +54,7 @@ class InstanceEndpoint(BaseAPIView):
IS_GOOGLE_ENABLED,
IS_GITHUB_ENABLED,
GITHUB_APP_NAME,
IS_GITLAB_ENABLED,
EMAIL_HOST,
ENABLE_MAGIC_LINK_LOGIN,
ENABLE_EMAIL_PASSWORD,
@@ -76,6 +77,10 @@ class InstanceEndpoint(BaseAPIView):
"key": "GITHUB_APP_NAME",
"default": os.environ.get("GITHUB_APP_NAME", ""),
},
{
"key": "IS_GITLAB_ENABLED",
"default": os.environ.get("IS_GITLAB_ENABLED", "0"),
},
{
"key": "EMAIL_HOST",
"default": os.environ.get("EMAIL_HOST", ""),
@@ -115,6 +120,7 @@ class InstanceEndpoint(BaseAPIView):
# Authentication
data["is_google_enabled"] = IS_GOOGLE_ENABLED == "1"
data["is_github_enabled"] = IS_GITHUB_ENABLED == "1"
data["is_gitlab_enabled"] = IS_GITLAB_ENABLED == "1"
data["is_magic_login_enabled"] = ENABLE_MAGIC_LINK_LOGIN == "1"
data["is_email_password_enabled"] = ENABLE_EMAIL_PASSWORD == "1"
@@ -65,6 +65,24 @@ class Command(BaseCommand):
"category": "GITHUB",
"is_encrypted": True,
},
{
"key": "GITLAB_HOST",
"value": os.environ.get("GITLAB_HOST"),
"category": "GITLAB",
"is_encrypted": False,
},
{
"key": "GITLAB_CLIENT_ID",
"value": os.environ.get("GITLAB_CLIENT_ID"),
"category": "GITLAB",
"is_encrypted": False,
},
{
"key": "GITLAB_CLIENT_SECRET",
"value": os.environ.get("GITLAB_CLIENT_SECRET"),
"category": "GITLAB",
"is_encrypted": True,
},
{
"key": "EMAIL_HOST",
"value": os.environ.get("EMAIL_HOST", ""),
@@ -151,7 +169,7 @@ class Command(BaseCommand):
)
)
keys = ["IS_GOOGLE_ENABLED", "IS_GITHUB_ENABLED"]
keys = ["IS_GOOGLE_ENABLED", "IS_GITHUB_ENABLED", "IS_GITLAB_ENABLED"]
if not InstanceConfiguration.objects.filter(key__in=keys).exists():
for key in keys:
if key == "IS_GOOGLE_ENABLED":
@@ -222,6 +240,46 @@ class Command(BaseCommand):
f"{key} loaded with value from environment variable."
)
)
if key == "IS_GITLAB_ENABLED":
GITLAB_HOST, GITLAB_CLIENT_ID, GITLAB_CLIENT_SECRET = (
get_configuration_value(
[
{
"key": "GITLAB_HOST",
"default": os.environ.get(
"GITLAB_HOST", "https://gitlab.com"
),
},
{
"key": "GITLAB_CLIENT_ID",
"default": os.environ.get(
"GITLAB_CLIENT_ID", ""
),
},
{
"key": "GITLAB_CLIENT_SECRET",
"default": os.environ.get(
"GITLAB_CLIENT_SECRET", ""
),
},
]
)
)
if bool(GITLAB_HOST) and bool(GITLAB_CLIENT_ID) and bool(GITLAB_CLIENT_SECRET):
value = "1"
else:
value = "0"
InstanceConfiguration.objects.create(
key="IS_GITLAB_ENABLED",
value=value,
category="AUTHENTICATION",
is_encrypted=False,
)
self.stdout.write(
self.style.SUCCESS(
f"{key} loaded with value from environment variable."
)
)
else:
for key in keys:
self.stdout.write(
+134 -131
View File
@@ -2,7 +2,20 @@
import json
from django.core.serializers.json import DjangoJSONEncoder
from django.db.models import Exists, F, Func, OuterRef, Q, Prefetch
from django.db.models import (
Exists,
F,
Func,
OuterRef,
Q,
Prefetch,
Case,
When,
CharField,
IntegerField,
Value,
Max,
)
# Django imports
from django.utils import timezone
@@ -30,19 +43,11 @@ from plane.db.models import (
DeployBoard,
IssueVote,
ProjectPublicMember,
State,
Label,
)
from plane.bgtasks.issue_activites_task import issue_activity
from plane.utils.grouper import (
issue_group_values,
issue_on_results,
issue_queryset_grouper,
)
from plane.utils.issue_filters import issue_filters
from plane.utils.order_queryset import order_issue_queryset
from plane.utils.paginator import (
GroupedOffsetPaginator,
SubGroupedOffsetPaginator,
)
# Module imports
from .base import BaseAPIView, BaseViewSet
@@ -516,24 +521,32 @@ class ProjectIssuesPublicEndpoint(BaseAPIView):
]
def get(self, request, anchor):
if not DeployBoard.objects.filter(
deploy_board = DeployBoard.objects.filter(
anchor=anchor, entity_name="project"
).exists():
).first()
if not deploy_board:
return Response(
{"error": "Project is not published"},
status=status.HTTP_404_NOT_FOUND,
)
project_deploy_board = DeployBoard.objects.get(
anchor=anchor, entity_name="project"
)
project_id = project_deploy_board.entity_identifier
slug = project_deploy_board.workspace.slug
filters = issue_filters(request.query_params, "GET")
# Custom ordering for priority and state
priority_order = ["urgent", "high", "medium", "low", "none"]
state_order = [
"backlog",
"unstarted",
"started",
"completed",
"cancelled",
]
order_by_param = request.GET.get("order_by", "-created_at")
project_id = deploy_board.entity_identifier
slug = deploy_board.workspace.slug
issue_queryset = (
Issue.issue_objects.annotate(
sub_issues_count=Issue.issue_objects.filter(
@@ -543,8 +556,8 @@ class ProjectIssuesPublicEndpoint(BaseAPIView):
.annotate(count=Func(F("id"), function="Count"))
.values("count")
)
.filter(project_id=project_deploy_board.project_id)
.filter(workspace_id=project_deploy_board.workspace_id)
.filter(project_id=project_id)
.filter(workspace__slug=slug)
.select_related("project", "workspace", "state", "parent")
.prefetch_related("assignees", "labels")
.prefetch_related(
@@ -561,6 +574,7 @@ class ProjectIssuesPublicEndpoint(BaseAPIView):
)
.filter(**filters)
.annotate(cycle_id=F("issue_cycle__cycle_id"))
.annotate(module_id=F("issue_module__module_id"))
.annotate(
link_count=IssueLink.objects.filter(issue=OuterRef("id"))
.order_by()
@@ -575,118 +589,107 @@ class ProjectIssuesPublicEndpoint(BaseAPIView):
.annotate(count=Func(F("id"), function="Count"))
.values("count")
)
.annotate(
sub_issues_count=Issue.issue_objects.filter(
parent=OuterRef("id")
)
.order_by()
.annotate(count=Func(F("id"), function="Count"))
.values("count")
)
# Priority Ordering
if order_by_param == "priority" or order_by_param == "-priority":
priority_order = (
priority_order
if order_by_param == "priority"
else priority_order[::-1]
)
).distinct()
order_by_param = request.GET.get("order_by", "-created_at")
issue_queryset = self.get_queryset().filter(**filters)
# Issue queryset
issue_queryset, order_by_param = order_issue_queryset(
issue_queryset=issue_queryset,
order_by_param=order_by_param,
)
# Group by
group_by = request.GET.get("group_by", False)
sub_group_by = request.GET.get("sub_group_by", False)
# issue queryset
issue_queryset = issue_queryset_grouper(
queryset=issue_queryset,
group_by=group_by,
sub_group_by=sub_group_by,
)
if group_by:
# Check group and sub group value paginate
if sub_group_by:
if group_by == sub_group_by:
return Response(
{
"error": "Group by and sub group by cannot have same parameters"
},
status=status.HTTP_400_BAD_REQUEST,
)
else:
# group and sub group pagination
return self.paginate(
request=request,
order_by=order_by_param,
queryset=issue_queryset,
on_results=lambda issues: issue_on_results(
group_by=group_by,
issues=issues,
sub_group_by=sub_group_by,
),
paginator_cls=SubGroupedOffsetPaginator,
group_by_fields=issue_group_values(
field=group_by,
slug=slug,
project_id=project_id,
filters=filters,
),
sub_group_by_fields=issue_group_values(
field=sub_group_by,
slug=slug,
project_id=project_id,
filters=filters,
),
group_by_field_name=group_by,
sub_group_by_field_name=sub_group_by,
count_filter=Q(
Q(issue_inbox__status=1)
| Q(issue_inbox__status=-1)
| Q(issue_inbox__status=2)
| Q(issue_inbox__isnull=True),
archived_at__isnull=True,
is_draft=False,
),
)
# Group Paginate
else:
# Group paginate
return self.paginate(
request=request,
order_by=order_by_param,
queryset=issue_queryset,
on_results=lambda issues: issue_on_results(
group_by=group_by,
issues=issues,
sub_group_by=sub_group_by,
),
paginator_cls=GroupedOffsetPaginator,
group_by_fields=issue_group_values(
field=group_by,
slug=slug,
project_id=project_id,
filters=filters,
),
group_by_field_name=group_by,
count_filter=Q(
Q(issue_inbox__status=1)
| Q(issue_inbox__status=-1)
| Q(issue_inbox__status=2)
| Q(issue_inbox__isnull=True),
archived_at__isnull=True,
is_draft=False,
),
issue_queryset = issue_queryset.annotate(
priority_order=Case(
*[
When(priority=p, then=Value(i))
for i, p in enumerate(priority_order)
],
output_field=CharField(),
)
).order_by("priority_order")
# State Ordering
elif order_by_param in [
"state__name",
"state__group",
"-state__name",
"-state__group",
]:
state_order = (
state_order
if order_by_param in ["state__name", "state__group"]
else state_order[::-1]
)
issue_queryset = issue_queryset.annotate(
state_order=Case(
*[
When(state__group=state_group, then=Value(i))
for i, state_group in enumerate(state_order)
],
default=Value(len(state_order)),
output_field=CharField(),
)
).order_by("state_order")
# assignee and label ordering
elif order_by_param in [
"labels__name",
"-labels__name",
"assignees__first_name",
"-assignees__first_name",
]:
issue_queryset = issue_queryset.annotate(
max_values=Max(
order_by_param[1::]
if order_by_param.startswith("-")
else order_by_param
)
).order_by(
"-max_values"
if order_by_param.startswith("-")
else "max_values"
)
else:
# List Paginate
return self.paginate(
order_by=order_by_param,
request=request,
queryset=issue_queryset,
on_results=lambda issues: issue_on_results(
group_by=group_by, issues=issues, sub_group_by=sub_group_by
issue_queryset = issue_queryset.order_by(order_by_param)
issues = IssuePublicSerializer(issue_queryset, many=True).data
state_group_order = [
"backlog",
"unstarted",
"started",
"completed",
"cancelled",
]
states = (
State.objects.filter(
~Q(name="Triage"),
workspace__slug=slug,
project_id=project_id,
)
.annotate(
custom_order=Case(
*[
When(group=value, then=Value(index))
for index, value in enumerate(state_group_order)
],
default=Value(len(state_group_order)),
output_field=IntegerField(),
),
)
.values("name", "group", "color", "id")
.order_by("custom_order", "sequence")
)
labels = Label.objects.filter(
workspace__slug=slug, project_id=project_id
).values("id", "name", "color", "parent")
return Response(
{
"issues": issues,
"states": states,
"labels": labels,
},
status=status.HTTP_200_OK,
)
+3 -1
View File
@@ -37,7 +37,9 @@ class WorkspaceProjectDeployBoardEndpoint(BaseAPIView):
]
def get(self, request, anchor):
deploy_board = DeployBoard.objects.filter(anchor=anchor, entity_name="project").values_list
deploy_board = DeployBoard.objects.filter(
anchor=anchor, entity_name="project"
).values_list
projects = (
Project.objects.filter(workspace=deploy_board.workspace)
.annotate(
+1 -1
View File
@@ -16,7 +16,7 @@ def log_exception(e):
if settings.DEBUG:
# Print the traceback if in debug mode
traceback.print_exc(e)
print(traceback.format_exc())
# Capture in sentry if configured
capture_exception(e)
+48 -13
View File
@@ -187,11 +187,11 @@ class OffsetPaginator:
class GroupedOffsetPaginator(OffsetPaginator):
# Field mappers
# Field mappers - list m2m fields here
FIELD_MAPPER = {
"labels__id": "label_ids",
"assignees__id": "assignee_ids",
"modules__id": "module_ids",
"issue_module__module_id": "module_ids",
}
def __init__(
@@ -205,8 +205,12 @@ class GroupedOffsetPaginator(OffsetPaginator):
):
# Initiate the parent class for all the parameters
super().__init__(queryset, *args, **kwargs)
# Set the group by field name
self.group_by_field_name = group_by_field_name
# Set the group by fields
self.group_by_fields = group_by_fields
# Set the count filter - this are extra filters that need to be passed to calculate the counts with the filters
self.count_filter = count_filter
def get_result(self, limit=50, cursor=None):
@@ -224,8 +228,11 @@ class GroupedOffsetPaginator(OffsetPaginator):
offset = cursor.offset * cursor.value
stop = offset + (cursor.value or limit) + 1
# Check if the offset is greater than the max offset
if self.max_offset is not None and offset >= self.max_offset:
raise BadPaginationError("Pagination offset too large")
# Check if the offset is less than 0
if offset < 0:
raise BadPaginationError("Pagination offset cannot be negative")
@@ -269,6 +276,8 @@ class GroupedOffsetPaginator(OffsetPaginator):
False,
queryset.filter(row_number__gte=stop).exists(),
)
# Add previous cursors
prev_cursor = Cursor(
limit,
page - 1,
@@ -305,7 +314,7 @@ class GroupedOffsetPaginator(OffsetPaginator):
)
def __get_total_queryset(self):
# Get total queryset
# Get total items for each group
return (
self.queryset.values(self.group_by_field_name)
.annotate(
@@ -328,7 +337,6 @@ class GroupedOffsetPaginator(OffsetPaginator):
)
+ (1 if group.get("count") == 0 else group.get("count"))
)
return total_group_dict
def __get_field_dict(self):
@@ -353,7 +361,7 @@ class GroupedOffsetPaginator(OffsetPaginator):
# Grouping for m2m values
total_group_dict = self.__get_total_dict()
# Preparing a dict to keep track of group IDs associated with each label ID
# Preparing a dict to keep track of group IDs associated with each entity ID
result_group_mapping = defaultdict(set)
# Preparing a dict to group result by group ID
grouped_by_field_name = defaultdict(list)
@@ -390,7 +398,7 @@ class GroupedOffsetPaginator(OffsetPaginator):
return processed_results
def __query_grouper(self, results):
# Grouping for single values
# Grouping for values that are not m2m
processed_results = self.__get_field_dict()
for result in results:
group_value = str(result.get(self.group_by_field_name))
@@ -411,10 +419,11 @@ class GroupedOffsetPaginator(OffsetPaginator):
class SubGroupedOffsetPaginator(OffsetPaginator):
# Field mappers this are the fields that are m2m
FIELD_MAPPER = {
"labels__id": "label_ids",
"assignees__id": "assignee_ids",
"modules__id": "module_ids",
"issue_module__module_id": "module_ids",
}
def __init__(
@@ -428,11 +437,18 @@ class SubGroupedOffsetPaginator(OffsetPaginator):
*args,
**kwargs,
):
# Initiate the parent class for all the parameters
super().__init__(queryset, *args, **kwargs)
# Set the group by field name
self.group_by_field_name = group_by_field_name
self.group_by_fields = group_by_fields
# Set the sub group by field name
self.sub_group_by_field_name = sub_group_by_field_name
self.sub_group_by_fields = sub_group_by_fields
# Set the count filter - this are extra filters that need to be passed to calculate the counts with the filters
self.count_filter = count_filter
def get_result(self, limit=30, cursor=None):
@@ -441,13 +457,19 @@ class SubGroupedOffsetPaginator(OffsetPaginator):
if cursor is None:
cursor = Cursor(0, 0, 0)
# get the minimum value
limit = min(limit, self.max_limit)
# Adjust the initial offset and stop based on the cursor and limit
queryset = self.queryset
# the current page
page = cursor.offset
# the offset
offset = cursor.offset * cursor.value
# the stop
stop = offset + (cursor.value or limit) + 1
if self.max_offset is not None and offset >= self.max_offset:
@@ -496,6 +518,8 @@ class SubGroupedOffsetPaginator(OffsetPaginator):
False,
queryset.filter(row_number__gte=stop).exists(),
)
# Add previous cursors
prev_cursor = Cursor(
limit,
page - 1,
@@ -579,19 +603,24 @@ class SubGroupedOffsetPaginator(OffsetPaginator):
subgroup = str(item[self.sub_group_by_field_name])
count = item["count"]
# Create a dictionary of group and sub group
if group not in total_sub_group_dict:
total_sub_group_dict[str(group)] = {}
# Create a dictionary of sub group
if subgroup not in total_sub_group_dict[group]:
total_sub_group_dict[str(group)][str(subgroup)] = {}
# Create a nested dictionary of group and sub group
total_sub_group_dict[group][subgroup] = count
return total_group_dict, total_sub_group_dict
def __get_field_dict(self):
# Create a field dictionary
total_group_dict, total_sub_group_dict = self.__get_total_dict()
# Create a dictionary of group and sub group
return {
str(group): {
"results": {
@@ -621,7 +650,6 @@ class SubGroupedOffsetPaginator(OffsetPaginator):
result_id = result["id"]
group_id = result[self.group_by_field_name]
result_group_mapping[str(result_id)].add(str(group_id))
# Use the same calculation for the sub group
if self.sub_group_by_field_name in self.FIELD_MAPPER:
for result in results:
@@ -635,6 +663,9 @@ class SubGroupedOffsetPaginator(OffsetPaginator):
group_value = str(result.get(self.group_by_field_name))
# Get the sub group value
sub_group_value = str(result.get(self.sub_group_by_field_name))
# Check if the group value is in the processed results
result_id = result["id"]
if (
group_value in processed_results
and sub_group_value
@@ -647,12 +678,14 @@ class SubGroupedOffsetPaginator(OffsetPaginator):
[] if "None" in group_ids else group_ids
)
if self.sub_group_by_field_name in self.FIELD_MAPPER:
sub_group_ids = list(result_group_mapping[str(result_id)])
# for multi groups
result[self.FIELD_MAPPER.get(self.group_by_field_name)] = (
[] if "None" in sub_group_ids else sub_group_ids
sub_group_ids = list(
result_sub_group_mapping[str(result_id)]
)
# for multi groups
result[
self.FIELD_MAPPER.get(self.sub_group_by_field_name)
] = ([] if "None" in sub_group_ids else sub_group_ids)
# If a result belongs to multiple groups, add it to each group
processed_results[str(group_value)]["results"][
str(sub_group_value)
]["results"].append(result)
@@ -677,8 +710,10 @@ class SubGroupedOffsetPaginator(OffsetPaginator):
self.group_by_field_name in self.FIELD_MAPPER
or self.sub_group_by_field_name in self.FIELD_MAPPER
):
# if the grouping is done through m2m then
processed_results = self.__query_multi_grouper(results=results)
else:
# group it directly
processed_results = self.__query_grouper(results=results)
else:
processed_results = {}
+1 -1
View File
@@ -147,7 +147,7 @@ services:
plane-redis:
<<: *app-env
image: redis:7.2.4-alpine
image: valkey/valkey:7.2.5-alpine
pull_policy: if_not_present
restart: unless-stopped
volumes:
+1 -1
View File
@@ -9,7 +9,7 @@ volumes:
services:
plane-redis:
image: redis:7.2.4-alpine
image: valkey/valkey:7.2.5-alpine
restart: unless-stopped
networks:
- dev_env
+1 -1
View File
@@ -116,7 +116,7 @@ services:
plane-redis:
container_name: plane-redis
image: redis:7.2.4-alpine
image: valkey/valkey:7.2.5-alpine
restart: always
volumes:
- redisdata:/data
+3 -2
View File
@@ -34,10 +34,11 @@
"prettier": "latest",
"prettier-plugin-tailwindcss": "^0.5.4",
"tailwindcss": "^3.3.3",
"turbo": "^1.13.2"
"turbo": "^2.0.4"
},
"resolutions": {
"@types/react": "18.2.48"
},
"packageManager": "yarn@1.22.19"
"packageManager": "yarn@1.22.22",
"name": "plane"
}
+91
View File
@@ -0,0 +1,91 @@
export enum EAuthPageTypes {
PUBLIC = "PUBLIC",
NON_AUTHENTICATED = "NON_AUTHENTICATED",
SET_PASSWORD = "SET_PASSWORD",
ONBOARDING = "ONBOARDING",
AUTHENTICATED = "AUTHENTICATED",
}
export enum EAuthModes {
SIGN_IN = "SIGN_IN",
SIGN_UP = "SIGN_UP",
}
export enum EAuthSteps {
EMAIL = "EMAIL",
PASSWORD = "PASSWORD",
UNIQUE_CODE = "UNIQUE_CODE",
}
// TODO: remove this
export enum EErrorAlertType {
BANNER_ALERT = "BANNER_ALERT",
INLINE_FIRST_NAME = "INLINE_FIRST_NAME",
INLINE_EMAIL = "INLINE_EMAIL",
INLINE_PASSWORD = "INLINE_PASSWORD",
INLINE_EMAIL_CODE = "INLINE_EMAIL_CODE",
}
export enum EAuthErrorCodes {
// Global
INSTANCE_NOT_CONFIGURED = "5000",
INVALID_EMAIL = "5005",
EMAIL_REQUIRED = "5010",
SIGNUP_DISABLED = "5015",
MAGIC_LINK_LOGIN_DISABLED = "5016",
PASSWORD_LOGIN_DISABLED = "5018",
USER_ACCOUNT_DEACTIVATED = "5019",
// Password strength
INVALID_PASSWORD = "5020",
SMTP_NOT_CONFIGURED = "5025",
// Sign Up
USER_ALREADY_EXIST = "5030",
AUTHENTICATION_FAILED_SIGN_UP = "5035",
REQUIRED_EMAIL_PASSWORD_SIGN_UP = "5040",
INVALID_EMAIL_SIGN_UP = "5045",
INVALID_EMAIL_MAGIC_SIGN_UP = "5050",
MAGIC_SIGN_UP_EMAIL_CODE_REQUIRED = "5055",
// Sign In
USER_DOES_NOT_EXIST = "5060",
AUTHENTICATION_FAILED_SIGN_IN = "5065",
REQUIRED_EMAIL_PASSWORD_SIGN_IN = "5070",
INVALID_EMAIL_SIGN_IN = "5075",
INVALID_EMAIL_MAGIC_SIGN_IN = "5080",
MAGIC_SIGN_IN_EMAIL_CODE_REQUIRED = "5085",
// Both Sign in and Sign up for magic
INVALID_MAGIC_CODE_SIGN_IN = "5090",
INVALID_MAGIC_CODE_SIGN_UP = "5092",
EXPIRED_MAGIC_CODE_SIGN_IN = "5095",
EXPIRED_MAGIC_CODE_SIGN_UP = "5097",
EMAIL_CODE_ATTEMPT_EXHAUSTED_SIGN_IN = "5100",
EMAIL_CODE_ATTEMPT_EXHAUSTED_SIGN_UP = "5102",
// Oauth
OAUTH_NOT_CONFIGURED = "5104",
GOOGLE_NOT_CONFIGURED = "5105",
GITHUB_NOT_CONFIGURED = "5110",
GITLAB_NOT_CONFIGURED = "5111",
GOOGLE_OAUTH_PROVIDER_ERROR = "5115",
GITHUB_OAUTH_PROVIDER_ERROR = "5120",
GITLAB_OAUTH_PROVIDER_ERROR = "5121",
// Reset Password
INVALID_PASSWORD_TOKEN = "5125",
EXPIRED_PASSWORD_TOKEN = "5130",
// Change password
INCORRECT_OLD_PASSWORD = "5135",
MISSING_PASSWORD = "5138",
INVALID_NEW_PASSWORD = "5140",
// set passowrd
PASSWORD_ALREADY_SET = "5145",
// Admin
ADMIN_ALREADY_EXIST = "5150",
REQUIRED_ADMIN_EMAIL_PASSWORD_FIRST_NAME = "5155",
INVALID_ADMIN_EMAIL = "5160",
INVALID_ADMIN_PASSWORD = "5165",
REQUIRED_ADMIN_EMAIL_PASSWORD = "5170",
ADMIN_AUTHENTICATION_FAILED = "5175",
ADMIN_USER_ALREADY_EXIST = "5180",
ADMIN_USER_DOES_NOT_EXIST = "5185",
ADMIN_USER_DEACTIVATED = "5190",
// Rate limit
RATE_LIMIT_EXCEEDED = "5900",
}
+6
View File
@@ -0,0 +1,6 @@
{
"name": "@plane/constants",
"version": "0.21.0",
"private": true,
"main": "./index.ts"
}
@@ -59,6 +59,9 @@ export const useDocumentEditor = ({
// indexedDB provider
useLayoutEffect(() => {
const localProvider = new IndexeddbPersistence(id, provider.document);
localProvider.on("synced", () => {
provider.setHasIndexedDBSynced(true);
});
return () => {
localProvider?.destroy();
};
@@ -13,6 +13,10 @@ export interface CompleteCollaboratorProviderConfiguration {
* onChange callback
*/
onChange: (updates: Uint8Array) => void;
/**
* Whether connection to the database has been established and all available content has been loaded or not.
*/
hasIndexedDBSynced: boolean;
}
export type CollaborationProviderConfiguration = Required<Pick<CompleteCollaboratorProviderConfiguration, "name">> &
@@ -24,6 +28,7 @@ export class CollaborationProvider {
// @ts-expect-error cannot be undefined
document: undefined,
onChange: () => {},
hasIndexedDBSynced: false,
};
constructor(configuration: CollaborationProviderConfiguration) {
@@ -45,7 +50,12 @@ export class CollaborationProvider {
return this.configuration.document;
}
setHasIndexedDBSynced(hasIndexedDBSynced: boolean) {
this.configuration.hasIndexedDBSynced = hasIndexedDBSynced;
}
documentUpdateHandler(update: Uint8Array, origin: any) {
if (!this.configuration.hasIndexedDBSynced) return;
// return if the update is from the provider itself
if (origin === this) return;
+12 -6
View File
@@ -1,10 +1,5 @@
module.exports = {
extends: [
"next",
"turbo",
"prettier",
"plugin:@typescript-eslint/recommended",
],
extends: ["next", "prettier", "plugin:@typescript-eslint/recommended"],
parser: "@typescript-eslint/parser",
parserOptions: {
ecmaVersion: 2021, // Or the ECMAScript version you are using
@@ -16,11 +11,20 @@ module.exports = {
rootDir: ["web/", "space/", "admin/", "packages/*/"],
},
},
globals: {
React: "readonly",
JSX: "readonly",
},
rules: {
"no-useless-escape": "off",
"prefer-const": "error",
"no-irregular-whitespace": "error",
"no-trailing-spaces": "error",
"no-duplicate-imports": "error",
"no-useless-catch": "warn",
"no-case-declarations": "error",
"no-undef": "error",
"no-unreachable": "error",
"arrow-body-style": ["error", "as-needed"],
"@next/next/no-html-link-for-pages": "off",
"@next/next/no-img-element": "off",
@@ -28,6 +32,7 @@ module.exports = {
"react/self-closing-comp": ["error", { component: true, html: true }],
"react/jsx-boolean-value": "error",
"react/jsx-no-duplicate-props": "error",
"react-hooks/exhaustive-deps": "warn",
"@typescript-eslint/no-unused-vars": ["error"],
"@typescript-eslint/no-explicit-any": "warn",
"@typescript-eslint/no-useless-empty-export": "error",
@@ -37,6 +42,7 @@ module.exports = {
{
selector: ["function", "variable"],
format: ["camelCase", "snake_case", "UPPER_CASE", "PascalCase"],
leadingUnderscore: "allow",
},
],
},
+1 -1
View File
@@ -4,7 +4,7 @@ export type TCurrentUserAccount = {
user: string | undefined;
provider_account_id: string | undefined;
provider: "google" | "github" | string | undefined;
provider: "google" | "github" | "gitlab" | string | undefined;
access_token: string | undefined;
access_token_expired_at: Date | undefined;
refresh_token: string | undefined;
+10
View File
@@ -75,3 +75,13 @@ export type TEstimateUpdateStageKeys =
| EEstimateUpdateStages.CREATE
| EEstimateUpdateStages.EDIT
| EEstimateUpdateStages.SWITCH;
export type TEstimateTypeErrorObject = {
oldValue: string;
newValue: string;
message: string | undefined;
};
export type TEstimateTypeError =
| Record<number, TEstimateTypeErrorObject>
| undefined;
+9 -2
View File
@@ -3,7 +3,8 @@ export type TInstanceAuthenticationMethodKeys =
| "ENABLE_MAGIC_LINK_LOGIN"
| "ENABLE_EMAIL_PASSWORD"
| "IS_GOOGLE_ENABLED"
| "IS_GITHUB_ENABLED";
| "IS_GITHUB_ENABLED"
| "IS_GITLAB_ENABLED";
export type TInstanceGoogleAuthenticationConfigurationKeys =
| "GOOGLE_CLIENT_ID"
@@ -13,9 +14,15 @@ export type TInstanceGithubAuthenticationConfigurationKeys =
| "GITHUB_CLIENT_ID"
| "GITHUB_CLIENT_SECRET";
export type TInstanceGitlabAuthenticationConfigurationKeys =
| "GITLAB_HOST"
| "GITLAB_CLIENT_ID"
| "GITLAB_CLIENT_SECRET";
type TInstanceAuthenticationConfigurationKeys =
| TInstanceGoogleAuthenticationConfigurationKeys
| TInstanceGithubAuthenticationConfigurationKeys;
| TInstanceGithubAuthenticationConfigurationKeys
| TInstanceGitlabAuthenticationConfigurationKeys;
export type TInstanceAuthenticationKeys =
| TInstanceAuthenticationMethodKeys
+1
View File
@@ -38,6 +38,7 @@ export interface IInstance {
export interface IInstanceConfig {
is_google_enabled: boolean;
is_github_enabled: boolean;
is_gitlab_enabled: boolean;
is_magic_login_enabled: boolean;
is_email_password_enabled: boolean;
github_app_name: string | undefined;
+3 -2
View File
@@ -3,6 +3,7 @@ import { EPageAccess } from "./enums";
export type TPage = {
access: EPageAccess | undefined;
anchor?: string | null | undefined;
archived_at: string | null | undefined;
color: string | undefined;
created_at: Date | undefined;
@@ -11,10 +12,10 @@ export type TPage = {
id: string | undefined;
is_favorite: boolean;
is_locked: boolean;
labels: string[] | undefined;
label_ids: string[] | undefined;
name: string | undefined;
owned_by: string | undefined;
project: string | undefined;
project_ids: string[] | undefined;
updated_at: Date | undefined;
updated_by: string | undefined;
workspace: string | undefined;
+10 -7
View File
@@ -1,6 +1,6 @@
import { IProject, IProjectLite, IWorkspaceLite } from "@plane/types";
export type TPublishEntityType = "project";
export type TPublishEntityType = "project" | "page";
export type TProjectPublishLayouts =
| "calendar"
@@ -9,7 +9,7 @@ export type TProjectPublishLayouts =
| "list"
| "spreadsheet";
export type TPublishViewProps = {
export type TProjectPublishViewProps = {
calendar?: boolean;
gantt?: boolean;
kanban?: boolean;
@@ -20,22 +20,25 @@ export type TPublishViewProps = {
export type TProjectDetails = IProjectLite &
Pick<IProject, "cover_image" | "logo_props" | "description">;
export type TPublishSettings = {
type TPublishSettings = {
anchor: string | undefined;
is_comments_enabled: boolean;
created_at: string | undefined;
created_by: string | undefined;
entity_identifier: string | undefined;
entity_name: TPublishEntityType | undefined;
id: string | undefined;
inbox: unknown;
is_comments_enabled: boolean;
is_reactions_enabled: boolean;
is_votes_enabled: boolean;
project: string | undefined;
project_details: TProjectDetails | undefined;
is_reactions_enabled: boolean;
updated_at: string | undefined;
updated_by: string | undefined;
view_props: TViewProps | undefined;
is_votes_enabled: boolean;
workspace: string | undefined;
workspace_detail: IWorkspaceLite | undefined;
};
export type TProjectPublishSettings = TPublishSettings & {
view_props: TProjectPublishViewProps | undefined;
};
+1 -1
View File
@@ -5,7 +5,7 @@ import {
TStateGroups,
} from ".";
type TLoginMediums = "email" | "magic-code" | "github" | "google";
type TLoginMediums = "email" | "magic-code" | "github" | "gitlab" | "google";
export interface IUser {
id: string;
+3
View File
@@ -13,6 +13,9 @@ export interface IProjectView {
is_favorite: boolean;
created_by: string;
updated_by: string;
isLocked: boolean | null;
isPrivate: boolean | null;
isPublished: boolean | null;
name: string;
description: string;
filters: IIssueFilterOptions;
+9 -1
View File
@@ -112,6 +112,14 @@ export interface IWorkspaceIssueSearchResult {
workspace__slug: string;
}
export interface IWorkspacePageSearchResult {
id: string;
name: string;
project_ids: string[];
project__identifiers: string[];
workspace__slug: string;
}
export interface IWorkspaceProjectSearchResult {
id: string;
identifier: string;
@@ -127,7 +135,7 @@ export interface IWorkspaceSearchResults {
cycle: IWorkspaceDefaultSearchResult[];
module: IWorkspaceDefaultSearchResult[];
issue_view: IWorkspaceDefaultSearchResult[];
page: IWorkspaceDefaultSearchResult[];
page: IWorkspacePageSearchResult[];
};
}
+2 -1
View File
@@ -34,7 +34,8 @@
"react-dom": "^18.2.0",
"react-popper": "^2.3.0",
"sonner": "^1.4.41",
"tailwind-merge": "^2.0.0"
"tailwind-merge": "^2.0.0",
"use-font-face-observer": "^1.2.2"
},
"devDependencies": {
"@chromatic-com/storybook": "^1.4.0",
+1 -1
View File
@@ -31,7 +31,7 @@ export const DropdownButton: React.FC<IMultiSelectDropdownButton | ISingleSelect
)}
onClick={handleOnClick}
>
{buttonContent ? <>{buttonContent(isOpen)}</> : <span className={cn("", buttonClassName)}>{value}</span>}
{buttonContent ? <>{buttonContent(isOpen, value)}</> : <span className={cn("", buttonClassName)}>{value}</span>}
</button>
</Combobox.Button>
);
+4 -2
View File
@@ -22,6 +22,7 @@ export const DropdownOptions: React.FC<IMultiSelectDropdownOptions | ISingleSele
disableSearch,
keyExtractor,
options,
handleClose,
value,
renderItem,
loader,
@@ -46,7 +47,7 @@ export const DropdownOptions: React.FC<IMultiSelectDropdownOptions | ISingleSele
options?.map((option) => (
<Combobox.Option
key={keyExtractor(option)}
value={option.data[option.value]}
value={option.value}
className={({ active, selected }) =>
cn(
"flex w-full cursor-pointer select-none items-center justify-between gap-2 truncate rounded px-1 py-1.5",
@@ -58,6 +59,7 @@ export const DropdownOptions: React.FC<IMultiSelectDropdownOptions | ISingleSele
option.className && option.className({ active, selected })
)
}
onClick={handleClose}
>
{({ selected }) => (
<>
@@ -65,7 +67,7 @@ export const DropdownOptions: React.FC<IMultiSelectDropdownOptions | ISingleSele
<>{renderItem({ value: option.data[option.value], selected })}</>
) : (
<>
<span className="flex-grow truncate">{value}</span>
<span className="flex-grow truncate">{option.value}</span>
{selected && <Check className="h-3.5 w-3.5 flex-shrink-0" />}
</>
)}
+5 -3
View File
@@ -24,8 +24,8 @@ export interface IDropdown {
// options props
keyExtractor: (option: TDropdownOption) => string;
optionsContainerClassName?: string;
queryArray: string[];
sortByKey: string;
queryArray?: string[];
sortByKey?: string;
firstItem?: (optionValue: string) => boolean;
renderItem?: ({ value, selected }: { value: string; selected: boolean }) => React.ReactNode;
loader?: React.ReactNode;
@@ -52,7 +52,7 @@ export interface ISingleSelectDropdown extends IDropdown {
export interface IDropdownButton {
isOpen: boolean;
buttonContent?: (isOpen: boolean) => React.ReactNode;
buttonContent?: (isOpen: boolean, value: string | string[] | undefined) => React.ReactNode;
buttonClassName?: string;
buttonContainerClassName?: string;
handleOnClick: (e: React.MouseEvent<HTMLButtonElement, MouseEvent>) => void;
@@ -79,6 +79,8 @@ export interface IDropdownOptions {
inputContainerClassName?: string;
disableSearch?: boolean;
handleClose?: () => void;
keyExtractor: (option: TDropdownOption) => string;
renderItem: (({ value, selected }: { value: string; selected: boolean }) => React.ReactNode) | undefined;
options: TDropdownOption[] | undefined;
+6 -4
View File
@@ -90,10 +90,12 @@ export const MultiSelectDropdown: FC<IMultiSelectDropdown> = (props) => {
const sortedOptions = useMemo(() => {
if (!options) return undefined;
const filteredOptions = (options || []).filter((options) => {
const queryString = queryArray.map((query) => options.data[query]).join(" ");
return queryString.toLowerCase().includes(query.toLowerCase());
});
const filteredOptions = queryArray
? (options || []).filter((options) => {
const queryString = queryArray.map((query) => options.data[query]).join(" ");
return queryString.toLowerCase().includes(query.toLowerCase());
})
: options;
if (disableSorting) return filteredOptions;
+8 -5
View File
@@ -90,12 +90,14 @@ export const Dropdown: FC<ISingleSelectDropdown> = (props) => {
const sortedOptions = useMemo(() => {
if (!options) return undefined;
const filteredOptions = (options || []).filter((options) => {
const queryString = queryArray.map((query) => options.data[query]).join(" ");
return queryString.toLowerCase().includes(query.toLowerCase());
});
const filteredOptions = queryArray
? (options || []).filter((options) => {
const queryString = queryArray.map((query) => options.data[query]).join(" ");
return queryString.toLowerCase().includes(query.toLowerCase());
})
: options;
if (disableSorting) return filteredOptions;
if (disableSorting || !sortByKey) return filteredOptions;
return sortBy(filteredOptions, [
(option) => firstItem && firstItem(option.data[option.value]),
@@ -157,6 +159,7 @@ export const Dropdown: FC<ISingleSelectDropdown> = (props) => {
value={value}
renderItem={renderItem}
loader={loader}
handleClose={handleClose}
/>
</div>
</Combobox.Options>
+11
View File
@@ -0,0 +1,11 @@
export const emojiCodeToUnicode = (emoji: string) => {
if (!emoji) return "";
// convert emoji code to unicode
const uniCodeEmoji = emoji
.split("-")
.map((emoji) => parseInt(emoji, 10).toString(16))
.join("-");
return uniCodeEmoji;
};
+25 -10
View File
@@ -1,13 +1,15 @@
import React, { useEffect, useState } from "react";
// icons
import { Search } from "lucide-react";
import { MATERIAL_ICONS_LIST } from "./icons";
import { InfoIcon } from "../icons";
// components
import { Input } from "../form-fields";
// hooks
import useFontFaceObserver from "use-font-face-observer";
// helpers
import { cn } from "../../helpers";
import { DEFAULT_COLORS, TIconsListProps, adjustColorForContrast } from "./emoji-icon-helper";
// icons
import { MATERIAL_ICONS_LIST } from "./icons";
import { InfoIcon } from "../icons";
import { Search } from "lucide-react";
export const IconsList: React.FC<TIconsListProps> = (props) => {
const { defaultColor, onChange } = props;
@@ -28,6 +30,15 @@ export const IconsList: React.FC<TIconsListProps> = (props) => {
const filteredArray = MATERIAL_ICONS_LIST.filter((icon) => icon.name.toLowerCase().includes(query.toLowerCase()));
const isMaterialSymbolsFontLoaded = useFontFaceObserver([
{
family: `Material Symbols Rounded`,
style: `normal`,
weight: `normal`,
stretch: `condensed`,
},
]);
return (
<>
<div className="flex items-center px-2 py-[15px] w-full ">
@@ -118,12 +129,16 @@ export const IconsList: React.FC<TIconsListProps> = (props) => {
});
}}
>
<span
style={{ color: activeColor }}
className="material-symbols-rounded !text-[1.25rem] !leading-[1.25rem]"
>
{icon.name}
</span>
{isMaterialSymbolsFontLoaded ? (
<span
style={{ color: activeColor }}
className="material-symbols-rounded !text-[1.25rem] !leading-[1.25rem]"
>
{icon.name}
</span>
) : (
<span className="size-5 rounded animate-pulse bg-custom-background-80" />
)}
</button>
))}
</div>
+1
View File
@@ -2,3 +2,4 @@ export * from "./emoji-icon-picker-new";
export * from "./emoji-icon-picker";
export * from "./emoji-icon-helper";
export * from "./icons";
export * from "./logo";
+100
View File
@@ -0,0 +1,100 @@
import React, { FC } from "react";
import { Emoji } from "emoji-picker-react";
import useFontFaceObserver from "use-font-face-observer";
// icons
import { LUCIDE_ICONS_LIST } from "./icons";
// helpers
import { emojiCodeToUnicode } from "./helpers";
type TLogoProps = {
in_use: "emoji" | "icon";
emoji?: {
value?: string;
url?: string;
};
icon?: {
name?: string;
color?: string;
};
};
type Props = {
logo: TLogoProps;
size?: number;
type?: "lucide" | "material";
};
export const Logo: FC<Props> = (props) => {
const { logo, size = 16, type = "material" } = props;
// destructuring the logo object
const { in_use, emoji, icon } = logo;
// derived values
const value = in_use === "emoji" ? emoji?.value : icon?.name;
const color = icon?.color;
const lucideIcon = LUCIDE_ICONS_LIST.find((item) => item.name === value);
const isMaterialSymbolsFontLoaded = useFontFaceObserver([
{
family: `Material Symbols Rounded`,
style: `normal`,
weight: `normal`,
stretch: `condensed`,
},
]);
// if no value, return empty fragment
if (!value) return <></>;
if (!isMaterialSymbolsFontLoaded) {
return (
<span
style={{
height: size,
width: size,
}}
className="rounded animate-pulse bg-custom-background-80"
/>
);
}
// emoji
if (in_use === "emoji") {
return <Emoji unified={emojiCodeToUnicode(value)} size={size} />;
}
// icon
if (in_use === "icon") {
return (
<>
{type === "lucide" ? (
<>
{lucideIcon && (
<lucideIcon.element
style={{
color: color,
height: size,
width: size,
}}
/>
)}
</>
) : (
<span
className="material-symbols-rounded"
style={{
fontSize: size,
color: color,
scale: "115%",
}}
>
{value}
</span>
)}
</>
);
}
// if no value, return empty fragment
return <></>;
};
+28
View File
@@ -0,0 +1,28 @@
import * as React from "react";
import { ISvgIcons } from "./type";
export const GitlabIcon: React.FC<ISvgIcons> = ({ width = "24", height = "24", className, color }) => (
<svg
width={width}
height={height}
className={className}
viewBox="0 0 24 24"
fill="currentColor"
xmlns="http://www.w3.org/2000/svg"
>
<g clipPath="url(#clip0_282_232)">
<path
fillRule="evenodd"
clipRule="evenodd"
d="M10 0C4.475 0 0 4.475 0 10C0 14.425 2.8625 18.1625 6.8375 19.4875C7.3375 19.575 7.525 19.275 7.525 19.0125C7.525 18.775 7.5125 17.9875 7.5125 17.15C5 17.6125 4.35 16.5375 4.15 15.975C4.0375 15.6875 3.55 14.8 3.125 14.5625C2.775 14.375 2.275 13.9125 3.1125 13.9C3.9 13.8875 4.4625 14.625 4.65 14.925C5.55 16.4375 6.9875 16.0125 7.5625 15.75C7.65 15.1 7.9125 14.6625 8.2 14.4125C5.975 14.1625 3.65 13.3 3.65 9.475C3.65 8.3875 4.0375 7.4875 4.675 6.7875C4.575 6.5375 4.225 5.5125 4.775 4.1375C4.775 4.1375 5.6125 3.875 7.525 5.1625C8.325 4.9375 9.175 4.825 10.025 4.825C10.875 4.825 11.725 4.9375 12.525 5.1625C14.4375 3.8625 15.275 4.1375 15.275 4.1375C15.825 5.5125 15.475 6.5375 15.375 6.7875C16.0125 7.4875 16.4 8.375 16.4 9.475C16.4 13.3125 14.0625 14.1625 11.8375 14.4125C12.2 14.725 12.5125 15.325 12.5125 16.2625C12.5125 17.6 12.5 18.675 12.5 19.0125C12.5 19.275 12.6875 19.5875 13.1875 19.4875C15.1726 18.8173 16.8976 17.5414 18.1197 15.8395C19.3418 14.1375 19.9994 12.0952 20 10C20 4.475 15.525 0 10 0Z"
fill={color ? color : "rgb(var(--color-text-200))"}
/>
</g>
<defs>
<clipPath id="clip0_282_232">
<rect width={width} height={height} />
</clipPath>
</defs>
</svg>
);
+1
View File
@@ -12,6 +12,7 @@ export * from "./dice-icon";
export * from "./discord-icon";
export * from "./full-screen-panel-icon";
export * from "./github-icon";
export * from "./gitlab-icon";
export * from "./layer-stack";
export * from "./layers-icon";
export * from "./photo-filter-icon";
+1
View File
@@ -7,6 +7,7 @@ export * from "./dropdowns";
export * from "./dropdown";
export * from "./form-fields";
export * from "./icons";
export * from "./modals";
export * from "./progress";
export * from "./spinners";
export * from "./tooltip";
@@ -1,11 +1,12 @@
"use client";
import React from "react";
import { AlertTriangle, Info, LucideIcon } from "lucide-react";
// ui
import { Button, TButtonVariant } from "@plane/ui";
// components
import { EModalPosition, EModalWidth, ModalCore } from "@/components/core";
import { Button, TButtonVariant } from "../button";
import { ModalCore } from "./modal-core";
// constants
import { EModalPosition, EModalWidth } from "./constants";
// helpers
import { cn } from "@/helpers/common.helper";
import { cn } from "../../helpers";
export type TModalVariant = "danger" | "primary";
+11
View File
@@ -0,0 +1,11 @@
export enum EModalPosition {
TOP = "flex items-center justify-center text-center mx-4 my-10 md:my-20",
CENTER = "flex items-end sm:items-center justify-center p-4 min-h-full",
}
export enum EModalWidth {
XL = "sm:max-w-xl",
XXL = "sm:max-w-2xl",
XXXL = "sm:max-w-3xl",
XXXXL = "sm:max-w-4xl",
}
+3
View File
@@ -0,0 +1,3 @@
export * from "./alert-modal";
export * from "./constants";
export * from "./modal-core";
@@ -1,19 +1,9 @@
import { Fragment } from "react";
import React, { Fragment } from "react";
import { Dialog, Transition } from "@headlessui/react";
// constants
import { EModalPosition, EModalWidth } from "./constants";
// helpers
import { cn } from "@/helpers/common.helper";
export enum EModalPosition {
TOP = "flex items-center justify-center text-center mx-4 my-10 md:my-20",
CENTER = "flex items-end sm:items-center justify-center p-4 min-h-full",
}
export enum EModalWidth {
XL = "sm:max-w-xl",
XXL = "sm:max-w-2xl",
XXXL = "sm:max-w-3xl",
XXXXL = "sm:max-w-4xl",
}
import { cn } from "../../helpers";
type Props = {
children: React.ReactNode;
@@ -1,6 +1,6 @@
import { notFound, redirect } from "next/navigation";
// types
import { TPublishSettings } from "@plane/types";
import { TProjectPublishSettings } from "@plane/types";
// services
import PublishService from "@/services/publish.service";
@@ -20,7 +20,7 @@ export default async function IssuesPage(props: Props) {
const { workspaceSlug, projectId } = params;
const { board, peekId } = searchParams;
let response: TPublishSettings | undefined = undefined;
let response: TProjectPublishSettings | undefined = undefined;
try {
response = await publishService.fetchAnchorFromProjectDetails(workspaceSlug, projectId);
} catch (error) {
+19 -2
View File
@@ -7,7 +7,7 @@ import useSWR from "swr";
import { LogoSpinner } from "@/components/common";
import { IssuesNavbarRoot } from "@/components/issues";
// hooks
import { usePublish, usePublishList } from "@/hooks/store";
import { useIssueFilter, usePublish, usePublishList } from "@/hooks/store";
// assets
import planeLogo from "@/public/plane-logo.svg";
@@ -25,8 +25,25 @@ const IssuesLayout = observer((props: Props) => {
// store hooks
const { fetchPublishSettings } = usePublishList();
const publishSettings = usePublish(anchor);
const { updateLayoutOptions } = useIssueFilter();
// fetch publish settings
useSWR(anchor ? `PUBLISH_SETTINGS_${anchor}` : null, anchor ? () => fetchPublishSettings(anchor) : null);
useSWR(
anchor ? `PUBLISH_SETTINGS_${anchor}` : null,
anchor
? async () => {
const response = await fetchPublishSettings(anchor);
if (response.view_props) {
updateLayoutOptions({
list: !!response.view_props.list,
kanban: !!response.view_props.kanban,
calendar: !!response.view_props.calendar,
gantt: !!response.view_props.gantt,
spreadsheet: !!response.view_props.spreadsheet,
});
}
}
: null
);
if (!publishSettings) return <LogoSpinner />;
+8
View File
@@ -0,0 +1,8 @@
// store
import { CoreRootStore } from "@/store/root.store";
export class RootStore extends CoreRootStore {
constructor() {
super();
}
}
@@ -85,7 +85,7 @@ export const AuthRoot: FC = observer(() => {
const isSMTPConfigured = config?.is_smtp_configured || false;
const isMagicLoginEnabled = config?.is_magic_login_enabled || false;
const isEmailPasswordEnabled = config?.is_email_password_enabled || false;
const isOAuthEnabled = (config && (config?.is_google_enabled || config?.is_github_enabled)) || false;
const isOAuthEnabled = (config && (config?.is_google_enabled || config?.is_github_enabled || config?.is_gitlab_enabled)) || false;
// submit handler- email verification
const handleEmailVerification = async (data: IEmailCheckData) => {
@@ -66,9 +66,10 @@ export const AuthEmailForm: FC<TAuthEmailForm> = observer((props) => {
autoFocus
/>
{email.length > 0 && (
<div className="flex-shrink-0 h-5 w-5 mr-2 bg-onboarding-background-200 hover:cursor-pointer">
<XCircle className="h-5 w-5 stroke-custom-text-400" onClick={() => setEmail("")} />
</div>
<XCircle
className="absolute right-3 h-5 w-5 stroke-custom-text-400 hover:cursor-pointer"
onClick={() => setEmail("")}
/>
)}
</div>
{emailError?.email && !isFocused && (
@@ -117,9 +117,10 @@ export const AuthPasswordForm: React.FC<Props> = observer((props: Props) => {
disabled
/>
{passwordFormData.email.length > 0 && (
<div className="flex-shrink-0 h-5 w-5 mr-2 bg-onboarding-background-200 hover:cursor-pointer">
<XCircle className="h-5 w-5 stroke-custom-text-400" onClick={handleEmailClear} />
</div>
<XCircle
className="absolute right-3 h-5 w-5 stroke-custom-text-400 hover:cursor-pointer"
onClick={handleEmailClear}
/>
)}
</div>
</div>
@@ -101,9 +101,10 @@ export const AuthUniqueCodeForm: React.FC<TAuthUniqueCodeForm> = (props) => {
disabled
/>
{uniqueCodeFormData.email.length > 0 && (
<div className="flex-shrink-0 h-5 w-5 mr-2 bg-onboarding-background-200 hover:cursor-pointer">
<XCircle className="h-5 w-5 stroke-custom-text-400" onClick={handleEmailClear} />
</div>
<XCircle
className="absolute right-3 h-5 w-5 stroke-custom-text-400 hover:cursor-pointer"
onClick={handleEmailClear}
/>
)}
</div>
</div>
@@ -0,0 +1,36 @@
import { FC } from "react";
import { useSearchParams } from "next/navigation";
import Image from "next/image";
import { useTheme } from "next-themes";
// helpers
import { API_BASE_URL } from "@/helpers/common.helper";
// images
import GitlabLogo from "/public/logos/gitlab-logo.svg";
export type GitlabOAuthButtonProps = {
text: string;
};
export const GitlabOAuthButton: FC<GitlabOAuthButtonProps> = (props) => {
const searchParams = useSearchParams();
const nextPath = searchParams.get("next_path") || undefined;
const { text } = props;
// hooks
const { resolvedTheme } = useTheme();
const handleSignIn = () => {
window.location.assign(`${API_BASE_URL}/auth/spaces/gitlab/${nextPath ? `?next_path=${nextPath}` : ``}`);
};
return (
<button
className={`flex h-[42px] w-full items-center justify-center gap-2 rounded border px-2 text-sm font-medium text-custom-text-100 duration-300 hover:bg-onboarding-background-300 ${
resolvedTheme === "dark" ? "border-[#43484F] bg-[#2F3135]" : "border-[#D9E4FF]"
}`}
onClick={handleSignIn}
>
<Image src={GitlabLogo} height={20} width={20} alt="GitLab Logo" />
{text}
</button>
);
};
@@ -1,3 +1,4 @@
export * from "./oauth-options";
export * from "./google-button";
export * from "./github-button";
export * from "./gitlab-button";
@@ -1,6 +1,6 @@
import { observer } from "mobx-react-lite";
// components
import { GithubOAuthButton, GoogleOAuthButton } from "@/components/account";
import { GithubOAuthButton, GitlabOAuthButton, GoogleOAuthButton } from "@/components/account";
// hooks
import { useInstance } from "@/hooks/store";
@@ -22,6 +22,7 @@ export const OAuthOptions: React.FC = observer(() => {
</div>
)}
{config?.is_github_enabled && <GithubOAuthButton text="Sign in with Github" />}
{config?.is_gitlab_enabled && <GitlabOAuthButton text="Sign in with GitLab" />}
</div>
</>
);

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