Compare commits

...
Author SHA1 Message Date
jon ⚝andGitHub 99e1963d9b feat: add GitLab OAuth client (#4692) 2024-06-14 14:55:59 +05:30
jon ⚝andGitHub 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
rahulrameshaandGitHub fb2b4ae303 save all filters and properties for views (#4728) 2024-06-07 19:43:27 +05:30
Anmol Singh BhatiaandGitHub de8da176d3 chore: issue and properties filter dropdown improvement (#4733) 2024-06-07 19:33:13 +05:30
Aaryan KhandelwalandGitHub 17ce1bceb6 chore: remove clear seleciton logic on escape key press (#4735) 2024-06-07 18:18:47 +05:30
Prateek ShouryaandGitHub 1561b710ca style: fix ux copy style on project feature preview page. (#4734) 2024-06-07 18:01:42 +05:30
Prateek ShouryaandGitHub 9a4971efa4 [WEB-1481] fix: inbox issue list update after changing issue status. (#4715) 2024-06-07 17:02:30 +05:30
Anmol Singh BhatiaandGitHub 2331404d46 chore: profile activity empty state added (#4732) 2024-06-07 16:26:52 +05:30
Anmol Singh BhatiaandGitHub a23c528396 fix: resolved border flicker on issue title (#4727) 2024-06-07 16:09:56 +05:30
Anmol Singh BhatiaandGitHub dee57326a5 [WEB-1535] chore: project logo picker improvement (#4718)
* chore: emoji icon picker improvement

* chore: emoji icon picker improvement
2024-06-07 16:09:27 +05:30
Anmol Singh BhatiaandGitHub 15918f2d9f fix: member list item custom menu placement (#4729) 2024-06-07 16:08:56 +05:30
Aaryan KhandelwalandGitHub 8b6a48f05c fix: don't add as a sub-issue if parent has been removed (#4731) 2024-06-07 16:08:21 +05:30
Aaryan KhandelwalandGitHub 1c849103f9 chore: added disabled prop to multiple select components (#4724)
* chore: added disabled prop to mutliple select group hoc

* style: fix empty space
2024-06-07 13:59:57 +05:30
Anmol Singh BhatiaandGitHub cdb932ab67 [WEB-1201] dev: dropdowns (#4721)
* chore: lodash package added

* chore: dropdown key down hook added

* dev: dropdown component

* chore: build error and code refactor

* chore: readme file updated
2024-06-07 13:59:31 +05:30
M. PalanikannanandGitHub b1c7e6ae20 [WEB-1526] feat: add auto merge behaviour to task lists and fix infinite backspace case (#4703)
* feat: add auto merge behaviour to task lists

* fix: unhandled cases for taskItem and taskList

* fix: css task list such that toggling task list doesn't shift things

* fix: task list jumps around while trying create/delete things in between two task lists

* fix: remove filtering for generic transactions i.e. transactions with some meta data while tying to join things
2024-06-07 12:36:19 +05:30
M. PalanikannanandGitHub f5656111ee [WEB-1537] fix: inline code block size fixed for headers, etc (#4709)
* fix: inline code block size fixed for headers, etc

* feat: persisting focus accurately post converting the code block into text

* fix: typo in error handling
2024-06-07 12:34:57 +05:30
M. PalanikannanandGitHub 51758b774e fix: temporary fix exiting lines with slashes (#4725) 2024-06-07 12:22:55 +05:30
Prateek ShouryaandGitHub d31aaee32c [WEB-1529] chore: workspace sidebar updates. (#4710) 2024-06-07 12:22:30 +05:30
guru_sainathandGitHub 9af9268be6 fix: Overflowing loader in issue edit modal (#4720) 2024-06-06 17:46:49 +05:30
pushya22andGitHub c18a6a9654 Merge pull request #4719 from makeplane/fix/project-identifier
[WEB-1540] chore: handle undefined identifier error
2024-06-06 17:12:16 +05:30
Aaryan Khandelwal 282597bf83 chore: handle undefined identifier case 2024-06-06 17:02:47 +05:30
rahulrameshaandGitHub b24e530816 [WEB-1533] chore: fix alignment issues in List and Spreadsheet view (#4714)
* fix alignment issues in List and Spreadsheet view

* fix spreadsheet indentation
2024-06-06 11:03:56 +05:30
NikhilandGitHub ca9f3f2f5a fix: add version max length (#4713) 2024-06-05 20:13:28 +05:30
sriram veeraghanta 028e70c4c1 Merge branch 'develop' of github.com:makeplane/plane into preview 2024-06-05 15:58:49 +05:30
Nikhilandsriram veeraghanta 30fdc1015c fix: cache invalidation on new members invite (#4699) 2024-06-05 15:57:27 +05:30
Henit ChobisaandGitHub 272428b05e fix: build test pull request running on non draft PRs (#4708) 2024-06-05 15:56:36 +05:30
NikhilandGitHub 911832d546 fix: cache invalidation on new members invite (#4699) 2024-06-05 15:17:43 +05:30
NikhilandGitHub 249e71e424 fix: email validation (#4707)
* fix: email validation on complete login or sign up functionality

* dev: add try catch block

* dev: split up code

* dev: empty return
2024-06-05 14:20:57 +05:30
rahulrameshaandGitHub 52d8d6e7ce [WEB-1517] chore: remove drag handle from list drag block (#4698)
* remove drag handle from list drag block

* align list group header with list item

* rearrange chevron for list subissues and rearrange spaces

* adding default draggable property to control link

* remove unnecessary dependencies for useEffect
2024-06-05 14:03:49 +05:30
Aaryan KhandelwalandGitHub 93a22034bd [WEB-1501] chore: update selected entity details on entities list change (#4702)
* chore: update selected entity detials on entities list change

* chore: addd selectionHelpers as a prop
2024-06-05 12:48:50 +05:30
Prateek ShouryaandGitHub 453459d271 [WEB-1459] chore: save users all / favorite project list collapse state into localstorage. (#4701) 2024-06-05 12:47:16 +05:30
M. PalanikannanandGitHub 8c5f693214 regression: focus changing issue with the peek overview editor (#4700) 2024-06-05 12:41:56 +05:30
sriram veeraghanta 4d17616670 fix: docker image build errors 2024-06-04 18:50:28 +05:30
sriram veeraghanta c190bf3a6f fix: regenerating lock file 2024-06-04 18:40:41 +05:30
Prateek ShouryaandGitHub e503c901ae [WEB-1521] chore: add configuration to enable/disable sign-ups. (#4697) 2024-06-04 15:35:20 +05:30
Satish GandhamandGitHub 188f8ff83f feat: Add components required for estimates (#4690)
* Add sortable, radio and typography components

* Remove stray css classes

* Prevent drag of items from other draggable

* Minor cleanup

* Update yarn.lock

* Remove radio input component as it was build on
headless ui v2.0.0 and now we are using v1.7.0

* Fix build errors

* Update dependencies in use memo.
2024-06-04 14:17:35 +05:30
Prateek ShouryaandGitHub 77b73dc032 [WEB-1481] fix: multiple API calls in inbox issues on closed issues tab. (#4691)
* fix: multiple API calls on scroll and closed issues tab.

* fix: pagination loader on initial load.
2024-06-04 13:18:25 +05:30
20acb0dd17 [WEB-1312] fix: trim file name before uploading (#4661)
* fix: trim file name before uploading

* fix: check the cursor position before inserting image

* dev: add trimming for file assets

* dev: add filename validation above if

* dev: make the validation to 50 to support user uploads

---------

Co-authored-by: pablohashescobar <nikhilschacko@gmail.com>
2024-06-04 13:14:26 +05:30
Prateek ShouryaandGitHub 7e66e2736b [WEB-1503] chore: add autofocus to name field in inline create/ update state component. (#4685) 2024-06-04 13:12:18 +05:30
Prateek ShouryaandGitHub cad55f3234 [WEB-1498] style: fix comments reaction alignment. (#4686) 2024-06-04 13:11:49 +05:30
Prateek ShouryaandGitHub 87582604f7 style: fix overlapping of response container in AI popover. (#4684) 2024-06-04 11:22:40 +05:30
Aaryan KhandelwalandGitHub dd65d03d33 [WEB-1184] feat: issue bulk operations (#4674)
* feat: issue bulk operations

* style: bulk operations action bar

* chore: remove edition separation
2024-06-04 11:12:24 +05:30
sriram veeraghanta 97eea75cb7 chore: update package version 2024-06-03 18:41:01 +05:30
Prateek ShouryaandGitHub ddfd953408 fix: inbox issue store update logic. (#4683) 2024-06-03 14:32:18 +05:30
guru_sainathandGitHub a428bc16c4 [WEB-1492] fix: resolved issue creation error in layouts while group_by and sub_group_by filters applied in quick add (#4682)
* fix: resolved issue creation error in layouts while group_by and sub_group_by filters applied in quick add

* fix: updated braces in conditions
2024-06-03 14:27:39 +05:30
198 changed files with 5088 additions and 2447 deletions
@@ -3,10 +3,11 @@ name: Build and Lint on Pull Request
on:
workflow_dispatch:
pull_request:
types: ["opened", "synchronize"]
types: ["opened", "synchronize", "ready_for_review"]
jobs:
get-changed-files:
if: github.event.pull_request.draft == false
runs-on: ubuntu-latest
outputs:
apiserver_changed: ${{ steps.changed-files.outputs.apiserver_any_changed }}
+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/docker-compose).
If you would like to self-host Plane, please see our [deployment guide](https://docs.plane.so/self-hosting/overview).
| 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;
+41 -3
View File
@@ -7,22 +7,24 @@ import { useTheme } from "next-themes";
import useSWR from "swr";
import { Mails, KeyRound } from "lucide-react";
import { TInstanceConfigurationKeys } from "@plane/types";
import { Loader, setPromiseToast } from "@plane/ui";
import { Loader, ToggleSwitch, setPromiseToast } from "@plane/ui";
// components
import { PageHeader } from "@/components/core";
// hooks
// helpers
import { resolveGeneralTheme } from "@/helpers/common.helper";
import { cn, resolveGeneralTheme } from "@/helpers/common.helper";
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";
@@ -45,6 +47,8 @@ const InstanceAuthenticationPage = observer(() => {
const [isSubmitting, setIsSubmitting] = useState<boolean>(false);
// theme
const { resolvedTheme } = useTheme();
// derived values
const enableSignUpConfig = formattedConfig?.ENABLE_SIGNUP ?? "";
const updateConfig = async (key: TInstanceConfigurationKeys, value: string) => {
setIsSubmitting(true);
@@ -114,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 (
@@ -129,7 +140,34 @@ const InstanceAuthenticationPage = observer(() => {
<div className="flex-grow overflow-hidden overflow-y-scroll vertical-scrollbar scrollbar-md px-4">
{formattedConfig ? (
<div className="space-y-3">
<div className="text-lg font-medium">Authentication modes</div>
<div className="text-lg font-medium pb-1">Sign-up configuration</div>
<div className={cn("w-full flex items-center gap-14 rounded")}>
<div className="flex grow items-center gap-4">
<div className="grow">
<div className={cn("font-medium leading-5 text-custom-text-100 text-sm")}>
Allow anyone to sign up without invite
</div>
<div className={cn("font-normal leading-5 text-custom-text-300 text-xs")}>
Toggling this off will disable self sign ups.
</div>
</div>
</div>
<div className={`shrink-0 pr-4 ${isSubmitting && "opacity-70"}`}>
<div className="flex items-center gap-4">
<ToggleSwitch
value={Boolean(parseInt(enableSignUpConfig))}
onChange={() => {
Boolean(parseInt(enableSignUpConfig)) === true
? updateConfig("ENABLE_SIGNUP", "0")
: updateConfig("ENABLE_SIGNUP", "1");
}}
size="sm"
disabled={isSubmitting}
/>
</div>
</div>
</div>
<div className="text-lg font-medium pt-6">Authentication modes</div>
{authenticationMethodsCard.map((method) => (
<AuthenticationMethodCard
key={method.key}
@@ -5,9 +5,11 @@ import { observer } from "mobx-react-lite";
import Link from "next/link";
import { ExternalLink, FileText, HelpCircle, MoveLeft } from "lucide-react";
import { Transition } from "@headlessui/react";
// ui
import { DiscordIcon, GithubIcon, Tooltip } from "@plane/ui";
// helpers
import { WEB_BASE_URL, cn } from "@/helpers/common.helper";
// hooks
import { WEB_BASE_URL } from "@/helpers/common.helper";
import { useTheme } from "@/hooks/store";
// assets
import packageJson from "package.json";
@@ -42,9 +44,12 @@ export const HelpSection: FC = observer(() => {
return (
<div
className={`flex w-full items-center justify-between gap-1 self-baseline border-t border-custom-sidebar-border-200 bg-custom-sidebar-background-100 px-4 py-2 ${
isSidebarCollapsed ? "flex-col" : ""
}`}
className={cn(
"flex w-full items-center justify-between gap-1 self-baseline border-t border-custom-border-200 bg-custom-sidebar-background-100 px-4 h-14 flex-shrink-0",
{
"flex-col h-auto py-1.5": isSidebarCollapsed,
}
)}
>
<div className={`flex items-center gap-1 ${isSidebarCollapsed ? "flex-col justify-center" : "w-full"}`}>
<Tooltip tooltipContent="Redirect to plane" position="right" className="ml-4" disabled={!isSidebarCollapsed}>
+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();
}
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "admin",
"version": "0.20.0",
"version": "0.21.0",
"private": true,
"scripts": {
"dev": "turbo run develop",
+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
+1 -1
View File
@@ -1,4 +1,4 @@
{
"name": "plane-api",
"version": "0.20.0"
"version": "0.21.0"
}
+122 -59
View File
@@ -4,6 +4,8 @@ import uuid
# Django imports
from django.utils import timezone
from django.core.validators import validate_email
from django.core.exceptions import ValidationError
# Third party imports
from zxcvbn import zxcvbn
@@ -46,68 +48,71 @@ class Adapter:
def authenticate(self):
raise NotImplementedError
def complete_login_or_signup(self):
email = self.user_data.get("email")
user = User.objects.filter(email=email).first()
# Check if sign up case or login
is_signup = bool(user)
if not user:
# New user
(ENABLE_SIGNUP,) = get_configuration_value(
[
{
"key": "ENABLE_SIGNUP",
"default": os.environ.get("ENABLE_SIGNUP", "1"),
},
]
)
if (
ENABLE_SIGNUP == "0"
and not WorkspaceMemberInvite.objects.filter(
email=email,
).exists()
):
raise AuthenticationException(
error_code=AUTHENTICATION_ERROR_CODES["SIGNUP_DISABLED"],
error_message="SIGNUP_DISABLED",
payload={"email": email},
)
user = User(email=email, username=uuid.uuid4().hex)
if self.user_data.get("user").get("is_password_autoset"):
user.set_password(uuid.uuid4().hex)
user.is_password_autoset = True
user.is_email_verified = True
else:
# Validate password
results = zxcvbn(self.code)
if results["score"] < 3:
raise AuthenticationException(
error_code=AUTHENTICATION_ERROR_CODES[
"INVALID_PASSWORD"
],
error_message="INVALID_PASSWORD",
payload={"email": email},
)
user.set_password(self.code)
user.is_password_autoset = False
avatar = self.user_data.get("user", {}).get("avatar", "")
first_name = self.user_data.get("user", {}).get("first_name", "")
last_name = self.user_data.get("user", {}).get("last_name", "")
user.avatar = avatar if avatar else ""
user.first_name = first_name if first_name else ""
user.last_name = last_name if last_name else ""
user.save()
Profile.objects.create(user=user)
if not user.is_active:
def sanitize_email(self, email):
# Check if email is present
if not email:
raise AuthenticationException(
AUTHENTICATION_ERROR_CODES["USER_ACCOUNT_DEACTIVATED"],
error_message="USER_ACCOUNT_DEACTIVATED",
error_code=AUTHENTICATION_ERROR_CODES["INVALID_EMAIL"],
error_message="INVALID_EMAIL",
payload={"email": email},
)
# Sanitize email
email = str(email).lower().strip()
# validate email
try:
validate_email(email)
except ValidationError:
raise AuthenticationException(
error_code=AUTHENTICATION_ERROR_CODES["INVALID_EMAIL"],
error_message="INVALID_EMAIL",
payload={"email": email},
)
# Return email
return email
def validate_password(self, email):
"""Validate password strength"""
results = zxcvbn(self.code)
if results["score"] < 3:
raise AuthenticationException(
error_code=AUTHENTICATION_ERROR_CODES["INVALID_PASSWORD"],
error_message="INVALID_PASSWORD",
payload={"email": email},
)
return
def __check_signup(self, email):
"""Check if sign up is enabled or not and raise exception if not enabled"""
# Get configuration value
(ENABLE_SIGNUP,) = get_configuration_value(
[
{
"key": "ENABLE_SIGNUP",
"default": os.environ.get("ENABLE_SIGNUP", "1"),
},
]
)
# Check if sign up is disabled and invite is present or not
if (
ENABLE_SIGNUP == "0"
and not WorkspaceMemberInvite.objects.filter(
email=email,
).exists()
):
# Raise exception
raise AuthenticationException(
error_code=AUTHENTICATION_ERROR_CODES["SIGNUP_DISABLED"],
error_message="SIGNUP_DISABLED",
payload={"email": email},
)
return True
def save_user_data(self, user):
# Update user details
user.last_login_medium = self.provider
user.last_active = timezone.now()
@@ -116,7 +121,63 @@ class Adapter:
user.last_login_uagent = self.request.META.get("HTTP_USER_AGENT")
user.token_updated_at = timezone.now()
user.save()
return user
def complete_login_or_signup(self):
# Get email
email = self.user_data.get("email")
# Sanitize email
email = self.sanitize_email(email)
# Check if the user is present
user = User.objects.filter(email=email).first()
# Check if sign up case or login
is_signup = bool(user)
# If user is not present, create a new user
if not user:
# New user
self.__check_signup(email)
# Initialize user
user = User(email=email, username=uuid.uuid4().hex)
# Check if password is autoset
if self.user_data.get("user").get("is_password_autoset"):
user.set_password(uuid.uuid4().hex)
user.is_password_autoset = True
user.is_email_verified = True
# Validate password
else:
# Validate password
self.validate_password(email)
# Set password
user.set_password(self.code)
user.is_password_autoset = False
# Set user details
avatar = self.user_data.get("user", {}).get("avatar", "")
first_name = self.user_data.get("user", {}).get("first_name", "")
last_name = self.user_data.get("user", {}).get("last_name", "")
user.avatar = avatar if avatar else ""
user.first_name = first_name if first_name else ""
user.last_name = last_name if last_name else ""
user.save()
# Create profile
Profile.objects.create(user=user)
if not user.is_active:
raise AuthenticationException(
AUTHENTICATION_ERROR_CODES["USER_ACCOUNT_DEACTIVATED"],
error_message="USER_ACCOUNT_DEACTIVATED",
)
# Save user data
user = self.save_user_data(user=user)
# Call callback if present
if self.callback:
self.callback(
user,
@@ -124,7 +185,9 @@ class Adapter:
self.request,
)
# Create or update account if token data is present
if self.token_data:
self.create_update_account(user=user)
# Return user
return user
@@ -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,
@@ -58,6 +61,8 @@ AUTHENTICATION_ERROR_CODES = {
"ADMIN_USER_DEACTIVATED": 5190,
# Rate limit
"RATE_LIMIT_EXCEEDED": 5900,
# Unknown
"AUTHENTICATION_FAILED": 5999,
}
+10 -10
View File
@@ -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),
@@ -81,11 +77,15 @@ 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"
)
if self.provider == "google":
code = "GOOGLE_OAUTH_PROVIDER_ERROR"
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],
error_message=str(code),
@@ -0,0 +1,145 @@
# 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):
(GITLAB_HOST,) = get_configuration_value(
[
{
"key": "GITLAB_HOST",
"default": os.environ.get("GITLAB_HOST", "https://gitlab.com"),
},
]
)
if not GITLAB_HOST:
raise AuthenticationException(
error_code=AUTHENTICATION_ERROR_CODES["GITLAB_NOT_CONFIGURED"],
error_message="GITLAB_NOT_CONFIGURED",
)
host = GITLAB_HOST
token_url = (
f"{host}/oauth/token"
)
userinfo_url = (
f"{host}/api/v4/user"
)
provider = "gitlab"
scope = "read_user"
def __init__(self, request, code=None, state=None, callback=None):
GITLAB_CLIENT_ID, GITLAB_CLIENT_SECRET = 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"),
},
]
)
if not (GITLAB_CLIENT_ID and GITLAB_CLIENT_SECRET):
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/",
@@ -4,6 +4,7 @@ from plane.db.models import (
WorkspaceMember,
WorkspaceMemberInvite,
)
from plane.utils.cache import invalidate_cache_directly
def process_workspace_project_invitations(user):
@@ -26,6 +27,16 @@ def process_workspace_project_invitations(user):
ignore_conflicts=True,
)
[
invalidate_cache_directly(
path=f"/api/workspaces/{str(workspace_member_invite.workspace.slug)}/members/",
url_params=False,
user=False,
multiple=True,
)
for workspace_member_invite in workspace_member_invites
]
# Check if user has any project invites
project_member_invites = ProjectMemberInvite.objects.filter(
email=user.email, accepted=True
@@ -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', '0066_account_id_token_cycle_logo_props_module_logo_props'),
]
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),
),
]
+1
View File
@@ -12,6 +12,7 @@ from .base import BaseModel
def get_upload_path(instance, filename):
filename = filename[:50]
if instance.workspace_id is not None:
return f"{instance.workspace.id}/{uuid4().hex}-{filename}"
return f"user-{uuid4().hex}-{filename}"
@@ -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"
@@ -59,6 +59,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", ""),
@@ -145,7 +163,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":
@@ -216,6 +234,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(
@@ -0,0 +1,43 @@
# Generated by Django 4.2.11 on 2024-06-05 13:41
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("license", "0002_rename_version_instance_current_version_and_more"),
]
operations = [
migrations.AlterField(
model_name="changelog",
name="title",
field=models.CharField(max_length=255),
),
migrations.AlterField(
model_name="changelog",
name="version",
field=models.CharField(max_length=255),
),
migrations.AlterField(
model_name="instance",
name="current_version",
field=models.CharField(max_length=255),
),
migrations.AlterField(
model_name="instance",
name="latest_version",
field=models.CharField(blank=True, max_length=255, null=True),
),
migrations.AlterField(
model_name="instance",
name="namespace",
field=models.CharField(blank=True, max_length=255, null=True),
),
migrations.AlterField(
model_name="instance",
name="product",
field=models.CharField(default="plane-ce", max_length=255),
),
]
+6 -6
View File
@@ -21,15 +21,15 @@ class Instance(BaseModel):
whitelist_emails = models.TextField(blank=True, null=True)
instance_id = models.CharField(max_length=255, unique=True)
license_key = models.CharField(max_length=256, null=True, blank=True)
current_version = models.CharField(max_length=10)
latest_version = models.CharField(max_length=10, null=True, blank=True)
current_version = models.CharField(max_length=255)
latest_version = models.CharField(max_length=255, null=True, blank=True)
product = models.CharField(
max_length=50, default=ProductTypes.PLANE_CE.value
max_length=255, default=ProductTypes.PLANE_CE.value
)
domain = models.TextField(blank=True)
# Instance specifics
last_checked_at = models.DateTimeField()
namespace = models.CharField(max_length=50, blank=True, null=True)
namespace = models.CharField(max_length=255, blank=True, null=True)
# telemetry and support
is_telemetry_enabled = models.BooleanField(default=True)
is_support_required = models.BooleanField(default=True)
@@ -86,9 +86,9 @@ class InstanceConfiguration(BaseModel):
class ChangeLog(BaseModel):
"""Change Log model to store the release changelogs made in the application."""
title = models.CharField(max_length=100)
title = models.CharField(max_length=255)
description = models.TextField(blank=True)
version = models.CharField(max_length=100)
version = models.CharField(max_length=255)
tags = models.JSONField(default=list)
release_date = models.DateTimeField(null=True)
is_release_candidate = models.BooleanField(default=False)
+1 -1
View File
@@ -66,7 +66,7 @@ def invalidate_cache_directly(
custom_path = path if path is not None else request.get_full_path()
auth_header = (
None
if request.user.is_anonymous
if request and request.user.is_anonymous
else str(request.user.id) if user else None
)
key = generate_cache_key(custom_path, auth_header)
+1 -1
View File
@@ -1,6 +1,6 @@
{
"repository": "https://github.com/makeplane/plane.git",
"version": "0.20.0",
"version": "0.21.0",
"license": "AGPL-3.0",
"private": true,
"workspaces": [
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@plane/constants",
"version": "0.20.0",
"version": "0.21.0",
"private": true,
"main": "./src/index.ts",
"exports": {
+15
View File
@@ -60,10 +60,13 @@ export enum EAuthErrorCodes {
EXPIRED_MAGIC_CODE = "5095",
EMAIL_CODE_ATTEMPT_EXHAUSTED = "5100",
// 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",
@@ -215,6 +218,10 @@ const errorCodeMessages: {
},
// Oauth
[EAuthenticationErrorCodes.OAUTH_NOT_CONFIGURED]: {
title: `OAuth not configured`,
message: () => `OAuth not configured. Please contact your administrator.`,
},
[EAuthenticationErrorCodes.GOOGLE_NOT_CONFIGURED]: {
title: `Google not configured`,
message: () => `Google not configured. Please contact your administrator.`,
@@ -223,6 +230,10 @@ const errorCodeMessages: {
title: `GitHub not configured`,
message: () => `GitHub not configured. Please contact your administrator.`,
},
[EAuthenticationErrorCodes.GITLAB_NOT_CONFIGURED]: {
title: `GitLab not configured`,
message: () => `GitLab not configured. Please contact your administrator.`,
},
[EAuthenticationErrorCodes.GOOGLE_OAUTH_PROVIDER_ERROR]: {
title: `Google OAuth provider error`,
message: () => `Google OAuth provider error. Please try again.`,
@@ -231,6 +242,10 @@ const errorCodeMessages: {
title: `GitHub OAuth provider error`,
message: () => `GitHub OAuth provider error. Please try again.`,
},
[EAuthenticationErrorCodes.GITLAB_OAUTH_PROVIDER_ERROR]: {
title: `GitLab OAuth provider error`,
message: () => `GitLab OAuth provider error. Please try again.`,
},
// Reset Password
[EAuthenticationErrorCodes.INVALID_PASSWORD_TOKEN]: {
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@plane/editor-core",
"version": "0.20.0",
"version": "0.21.0",
"description": "Core Editor that powers Plane",
"private": true,
"main": "./dist/index.mjs",
@@ -112,7 +112,7 @@ export const useEditor = ({
if (value === null || value === undefined) return;
if (editor && !editor.isDestroyed && !editor.storage.image.uploadInProgress) {
try {
editor.commands.setContent(value);
editor.commands.setContent(value, false, { preserveWhitespace: "full" });
const currentSavedSelection = savedSelectionRef.current;
if (currentSavedSelection) {
const docLength = editor.state.doc.content.size;
@@ -3,6 +3,7 @@ import { startImageUpload } from "src/ui/plugins/image/image-upload-handler";
import { findTableAncestor } from "src/lib/utils";
import { Selection } from "@tiptap/pm/state";
import { UploadImage } from "src/types/upload-image";
import { replaceCodeWithText } from "src/ui/extensions/code/utils/replace-code-block-with-text";
export const setText = (editor: Editor, range?: Range) => {
if (range) editor.chain().focus().deleteRange(range).clearNodes().run();
@@ -54,69 +55,11 @@ export const toggleUnderline = (editor: Editor, range?: Range) => {
else editor.chain().focus().toggleUnderline().run();
};
const replaceCodeBlockWithContent = (editor: Editor) => {
try {
const { schema } = editor.state;
const { paragraph } = schema.nodes;
let replaced = false;
const replaceCodeBlock = (from: number, to: number, textContent: string) => {
const docSize = editor.state.doc.content.size;
if (from < 0 || to > docSize || from > to) {
console.error("Invalid range for replacement: ", from, to, "in a document of size", docSize);
return;
}
// split the textContent by new lines to handle each line as a separate paragraph
const lines = textContent.split(/\r?\n/);
const tr = editor.state.tr;
// Calculate the position for inserting the first paragraph
let insertPos = from;
// Remove the code block first
tr.delete(from, to);
// For each line, create a paragraph node and insert it
lines.forEach((line) => {
const paragraphNode = paragraph.create({}, schema.text(line));
tr.insert(insertPos, paragraphNode);
// Update insertPos for the next insertion
insertPos += paragraphNode.nodeSize;
});
// Dispatch the transaction
editor.view.dispatch(tr);
replaced = true;
};
editor.state.doc.nodesBetween(editor.state.selection.from, editor.state.selection.to, (node, pos) => {
if (node.type === schema.nodes.codeBlock) {
const startPos = pos;
const endPos = pos + node.nodeSize;
const textContent = node.textContent;
if (textContent.length === 0) {
editor.chain().focus().toggleCodeBlock().run();
}
replaceCodeBlock(startPos, endPos, textContent);
return false;
}
});
if (!replaced) {
console.log("No code block to replace.");
}
} catch (error) {
console.error("An error occurred while replacing code block content:", error);
}
};
export const toggleCodeBlock = (editor: Editor, range?: Range) => {
try {
// if it's a code block, replace it with the code with paragraphs
if (editor.isActive("codeBlock")) {
replaceCodeBlockWithContent(editor);
replaceCodeWithText(editor);
return;
}
@@ -124,11 +67,16 @@ export const toggleCodeBlock = (editor: Editor, range?: Range) => {
const text = editor.state.doc.textBetween(from, to, "\n");
const isMultiline = text.includes("\n");
// if the selection is not a range i.e. empty, then simply convert it into a code block
if (editor.state.selection.empty) {
editor.chain().focus().toggleCodeBlock().run();
} else if (isMultiline) {
// if the selection is multiline, then also replace the text content with
// a code block
editor.chain().focus().deleteRange({ from, to }).insertContentAt(from, `\`\`\`\n${text}\n\`\`\``).run();
} else {
// if the selection is single line, then simply convert it into inline
// code
editor.chain().focus().toggleCode().run();
}
} catch (error) {
@@ -110,6 +110,11 @@ ul[data-type="taskList"] li > label input[type="checkbox"]:checked:hover {
}
}
/* the p tag just after the ul tag */
ul[data-type="taskList"] + p {
margin-top: 0.4rem !important;
}
ul[data-type="taskList"] li > label input[type="checkbox"] {
position: relative;
-webkit-appearance: none;
@@ -152,6 +157,10 @@ ul[data-type="taskList"] li > label input[type="checkbox"] {
}
}
ul[data-type="taskList"] li > div > p {
margin-top: 10px;
}
ul[data-type="taskList"] li[data-checked="true"] > div > p {
color: rgb(var(--color-text-400));
text-decoration: line-through;
@@ -33,7 +33,7 @@ export const CustomCodeInlineExtension = Mark.create<CodeOptions>({
return {
HTMLAttributes: {
class:
"rounded bg-custom-background-80 px-1 py-[2px] font-mono font-medium text-orange-500 border-[0.5px] border-custom-border-200 text-sm",
"rounded bg-custom-background-80 px-1 py-[2px] font-mono font-medium text-orange-500 border-[0.5px] border-custom-border-200",
spellcheck: "false",
},
};
@@ -0,0 +1,124 @@
import { Editor, findParentNode } from "@tiptap/core";
type ReplaceCodeBlockParams = {
editor: Editor;
from: number;
to: number;
textContent: string;
cursorPosInsideCodeblock: number;
};
export function replaceCodeWithText(editor: Editor): void {
try {
const { from, to } = editor.state.selection;
const cursorPosInsideCodeblock = from;
let replaced = false;
editor.state.doc.nodesBetween(from, to, (node, pos) => {
if (node.type === editor.state.schema.nodes.codeBlock) {
const startPos = pos;
const endPos = pos + node.nodeSize;
const textContent = node.textContent;
if (textContent.length === 0) {
editor.chain().focus().toggleCodeBlock().run();
} else {
transformCodeBlockToParagraphs({
editor,
from: startPos,
to: endPos,
textContent,
cursorPosInsideCodeblock,
});
}
replaced = true;
return false;
}
});
if (!replaced) {
console.log("No code block to replace.");
}
} catch (error) {
console.error("An error occurred while replacing code block content:", error);
}
}
function transformCodeBlockToParagraphs({
editor,
from,
to,
textContent,
cursorPosInsideCodeblock,
}: ReplaceCodeBlockParams): void {
const { schema } = editor.state;
const { paragraph } = schema.nodes;
const docSize = editor.state.doc.content.size;
if (from < 0 || to > docSize || from > to) {
console.error("Invalid range for replacement: ", from, to, "in a document of size", docSize);
return;
}
// Split the textContent by new lines to handle each line as a separate paragraph for Windows (\r\n) and Unix (\n)
const lines = textContent.split(/\r?\n/);
const tr = editor.state.tr;
let insertPos = from;
// Remove the code block first
tr.delete(from, to);
// For each line, create a paragraph node and insert it
lines.forEach((line) => {
// if the line is empty, create a paragraph node with no content
const paragraphNode = line.length === 0 ? paragraph.create({}) : paragraph.create({}, schema.text(line));
tr.insert(insertPos, paragraphNode);
insertPos += paragraphNode.nodeSize;
});
// Now persist the focus to the converted paragraph
const parentNodeOffset = findParentNode((node) => node.type === schema.nodes.codeBlock)(editor.state.selection)?.pos;
if (parentNodeOffset === undefined) throw new Error("Invalid code block offset");
const lineNumber = getLineNumber(textContent, cursorPosInsideCodeblock, parentNodeOffset);
const cursorPosOutsideCodeblock = cursorPosInsideCodeblock + (lineNumber - 1);
editor.view.dispatch(tr);
editor.chain().focus(cursorPosOutsideCodeblock).run();
}
/**
* Calculates the line number where the cursor is located inside the code block.
* Assumes the indexing of the content inside the code block is like ProseMirror's indexing.
*
* @param {string} textContent - The content of the code block.
* @param {number} cursorPosition - The absolute cursor position in the document.
* @param {number} codeBlockNodePos - The starting position of the code block node in the document.
* @returns {number} The 1-based line number where the cursor is located.
*/
function getLineNumber(textContent: string, cursorPosition: number, codeBlockNodePos: number): number {
// Split the text content into lines, handling both Unix and Windows newlines
const lines = textContent.split(/\r?\n/);
const cursorPosInsideCodeblockRelative = cursorPosition - codeBlockNodePos;
let startPosition = 0;
let lineNumber = 0;
for (let i = 0; i < lines.length; i++) {
// Calculate the end position of the current line
const endPosition = startPosition + lines[i].length + 1; // +1 for the newline character
// Check if the cursor position is within the current line
if (cursorPosInsideCodeblockRelative >= startPosition && cursorPosInsideCodeblockRelative <= endPosition) {
lineNumber = i + 1; // Line numbers are 1-based
break;
}
// Update the start position for the next line
startPosition = endPosition;
}
return lineNumber;
}
@@ -72,7 +72,7 @@ const getPrevListDepth = (typeOrName: string, state: EditorState) => {
// Traverse up the document structure from the adjusted position
for (let d = resolvedPos.depth; d > 0; d--) {
const node = resolvedPos.node(d);
if (node.type.name === "bulletList" || node.type.name === "orderedList") {
if (node.type.name === "bulletList" || node.type.name === "orderedList" || node.type.name === "taskList") {
// Increment depth for each list ancestor found
depth++;
}
@@ -146,6 +146,8 @@ export const handleBackspace = (editor: Editor, name: string, parentListTypes: s
if (!isAtStartOfNode(editor.state)) {
return false;
}
// is the paragraph node inside of the current list item (maybe with a hard break)
const isParaSibling = isCurrentParagraphASibling(editor.state);
const isCurrentListItemSublist = prevListIsHigher(name, editor.state);
const listItemPos = findListItemPos(name, editor.state);
@@ -306,7 +308,10 @@ const isCurrentParagraphASibling = (state: EditorState): boolean => {
const currentParagraphNode = $from.parent; // Get the current node where the selection is.
// Ensure we're in a paragraph and the parent is a list item.
if (currentParagraphNode.type.name === "paragraph" && listItemNode.type.name === "listItem") {
if (
currentParagraphNode.type.name === "paragraph" &&
(listItemNode.type.name === "listItem" || listItemNode.type.name === "taskItem")
) {
let paragraphNodesCount = 0;
listItemNode.forEach((child) => {
if (child.type.name === "paragraph") {
@@ -327,16 +332,19 @@ export function isCursorInSubList(editor: Editor) {
// Check if the current node is a list item
const listItem = editor.schema.nodes.listItem;
const taskItem = editor.schema.nodes.taskItem;
// Traverse up the document tree from the current position
for (let depth = $from.depth; depth > 0; depth--) {
const node = $from.node(depth);
if (node.type === listItem) {
if (node.type === listItem || node.type === taskItem) {
// If the parent of the list item is also a list, it's a sub-list
const parent = $from.node(depth - 1);
if (
parent &&
(parent.type === editor.schema.nodes.bulletList || parent.type === editor.schema.nodes.orderedList)
(parent.type === editor.schema.nodes.bulletList ||
parent.type === editor.schema.nodes.orderedList ||
parent.type === editor.schema.nodes.taskList)
) {
return true;
}
@@ -69,7 +69,7 @@ export const ListKeymap = ({ tabIndex }: { tabIndex?: number }) =>
return handled;
} catch (e) {
console.log("error in handling Backspac:", e);
console.log("Error in handling Delete:", e);
return false;
}
},
@@ -104,7 +104,7 @@ export const ListKeymap = ({ tabIndex }: { tabIndex?: number }) =>
return handled;
} catch (e) {
console.log("error in handling Backspac:", e);
console.log("Error in handling Backspace:", e);
return false;
}
},
@@ -15,9 +15,7 @@ declare module "@tiptap/core" {
}
}
function autoJoin(tr: Transaction, newTr: Transaction, nodeType: NodeType) {
if (!tr.isGeneric) return false;
function autoJoin(tr: Transaction, newTr: Transaction, nodeTypes: NodeType[]) {
// Find all ranges where we might want to join.
const ranges: Array<number> = [];
for (let i = 0; i < tr.mapping.maps.length; i++) {
@@ -28,7 +26,7 @@ function autoJoin(tr: Transaction, newTr: Transaction, nodeType: NodeType) {
// Figure out which joinable points exist inside those ranges,
// by checking all node boundaries in their parent nodes.
const joinable = [];
const joinable: number[] = [];
for (let i = 0; i < ranges.length; i += 2) {
const from = ranges[i],
to = ranges[i + 1];
@@ -40,7 +38,7 @@ function autoJoin(tr: Transaction, newTr: Transaction, nodeType: NodeType) {
if (!after) break;
if (index && joinable.indexOf(pos) == -1) {
const before = parent.child(index - 1);
if (before.type == after.type && before.type === nodeType) joinable.push(pos);
if (before.type == after.type && nodeTypes.includes(before.type)) joinable.push(pos);
}
pos += after.nodeSize;
}
@@ -88,25 +86,15 @@ export const CustomKeymap = Extension.create({
// Create a new transaction.
const newTr = newState.tr;
let joined = false;
for (const transaction of transactions) {
const anotherJoin = autoJoin(transaction, newTr, newState.schema.nodes["orderedList"]);
joined = anotherJoin || joined;
}
if (joined) {
return newTr;
}
},
}),
new Plugin({
key: new PluginKey("unordered-list-merging"),
appendTransaction(transactions, oldState, newState) {
// Create a new transaction.
const newTr = newState.tr;
const joinableNodes = [
newState.schema.nodes["orderedList"],
newState.schema.nodes["taskList"],
newState.schema.nodes["bulletList"],
];
let joined = false;
for (const transaction of transactions) {
const anotherJoin = autoJoin(transaction, newTr, newState.schema.nodes["bulletList"]);
const anotherJoin = autoJoin(transaction, newTr, joinableNodes);
joined = anotherJoin || joined;
}
if (joined) {
@@ -50,9 +50,25 @@ export async function startImageUpload(
};
try {
const fileNameTrimmed = trimFileName(file.name);
const fileWithTrimmedName = new File([file], fileNameTrimmed, { type: file.type });
const resolvedPos = view.state.doc.resolve(pos ?? 0);
const nodeBefore = resolvedPos.nodeBefore;
// if the image is at the start of the line i.e. when nodeBefore is null
if (nodeBefore === null) {
if (pos) {
// so that the image is not inserted at the next line, else incase the
// image is inserted at any line where there's some content, the
// position is kept as it is to be inserted at the next line
pos -= 1;
}
}
view.focus();
const src = await uploadAndValidateImage(file, uploadFile);
const src = await uploadAndValidateImage(fileWithTrimmedName, uploadFile);
if (src == null) {
throw new Error("Resolved image URL is undefined.");
@@ -112,3 +128,14 @@ async function uploadAndValidateImage(file: File, uploadFile: UploadImage): Prom
throw error;
}
}
function trimFileName(fileName: string, maxLength = 100) {
if (fileName.length > maxLength) {
const extension = fileName.split(".").pop();
const nameWithoutExtension = fileName.slice(0, -(extension?.length ?? 0 + 1));
const allowedNameLength = maxLength - (extension?.length ?? 0) - 1; // -1 for the dot
return `${nameWithoutExtension.slice(0, allowedNameLength)}.${extension}`;
}
return fileName;
}
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@plane/document-editor",
"version": "0.20.0",
"version": "0.21.0",
"description": "Package that powers Plane's Pages Editor",
"main": "./dist/index.mjs",
"module": "./dist/index.mjs",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@plane/editor-extensions",
"version": "0.20.0",
"version": "0.21.0",
"description": "Package that powers Plane's Editor with extensions",
"private": true,
"main": "./dist/index.mjs",
@@ -69,7 +69,6 @@ const Command = Extension.create<SlashCommandOptions>({
return true;
},
allowSpaces: true,
},
};
},
@@ -1,6 +1,6 @@
{
"name": "@plane/lite-text-editor",
"version": "0.20.0",
"version": "0.21.0",
"description": "Package that powers Plane's Comment Editor",
"private": true,
"main": "./dist/index.mjs",
@@ -1,6 +1,6 @@
{
"name": "@plane/rich-text-editor",
"version": "0.20.0",
"version": "0.21.0",
"description": "Rich Text Editor that powers Plane",
"private": true,
"main": "./dist/index.mjs",
+1 -1
View File
@@ -1,7 +1,7 @@
{
"name": "eslint-config-custom",
"private": true,
"version": "0.20.0",
"version": "0.21.0",
"main": "index.js",
"license": "MIT",
"devDependencies": {},
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "tailwind-config-custom",
"version": "0.20.0",
"version": "0.21.0",
"description": "common tailwind configuration across monorepo",
"main": "index.js",
"private": true,
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "tsconfig",
"version": "0.20.0",
"version": "0.21.0",
"private": true,
"files": [
"base.json",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@plane/types",
"version": "0.20.0",
"version": "0.21.0",
"private": true,
"main": "./src/index.d.ts"
}
+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;
+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;
+2 -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;
@@ -128,6 +128,7 @@ export interface IUserActivityResponse {
prev_page_results: boolean;
results: IIssueActivity[];
total_pages: number;
total_results: number;
}
export type UserAuth = {
+4 -1
View File
@@ -2,7 +2,7 @@
"name": "@plane/ui",
"description": "UI components shared across multiple apps internally",
"private": true,
"version": "0.20.0",
"version": "0.21.0",
"main": "./dist/index.js",
"module": "./dist/index.mjs",
"types": "./dist/index.d.ts",
@@ -20,12 +20,15 @@
"postcss": "postcss styles/globals.css -o styles/output.css --watch"
},
"dependencies": {
"@atlaskit/pragmatic-drag-and-drop": "^1.1.10",
"@atlaskit/pragmatic-drag-and-drop-hitbox": "^1.0.3",
"@blueprintjs/core": "^4.16.3",
"@blueprintjs/popover2": "^1.13.3",
"@headlessui/react": "^1.7.17",
"@popperjs/core": "^2.11.8",
"clsx": "^2.0.0",
"emoji-picker-react": "^4.5.16",
"lodash": "^4.17.21",
"lucide-react": "^0.379.0",
"react-color": "^2.19.3",
"react-dom": "^18.2.0",
+11 -2
View File
@@ -7,10 +7,11 @@ export type TControlLink = React.AnchorHTMLAttributes<HTMLAnchorElement> & {
target?: string;
disabled?: boolean;
className?: string;
draggable?: boolean;
};
export const ControlLink = React.forwardRef<HTMLAnchorElement, TControlLink>((props, ref) => {
const { href, onClick, children, target = "_self", disabled = false, className, ...rest } = props;
const { href, onClick, children, target = "_self", disabled = false, className, draggable = false, ...rest } = props;
const LEFT_CLICK_EVENT_CODE = 0;
const handleOnClick = (event: React.MouseEvent<HTMLAnchorElement, MouseEvent>) => {
@@ -33,7 +34,15 @@ export const ControlLink = React.forwardRef<HTMLAnchorElement, TControlLink>((pr
if (disabled) return <>{children}</>;
return (
<a href={href} target={target} onClick={handleOnClick} {...rest} ref={ref} className={className}>
<a
href={href}
target={target}
onClick={handleOnClick}
{...rest}
ref={ref}
className={className}
draggable={draggable}
>
{children}
</a>
);
+44
View File
@@ -0,0 +1,44 @@
Below is a detailed list of the props included:
### Root Props
- value: string | string[]; - Current selected value.
- onChange: (value: string | string []) => void; - Callback function for handling value changes.
- options: TDropdownOption[] | undefined; - Array of options.
- onOpen?: () => void; - Callback function triggered when the dropdown opens.
- onClose?: () => void; - Callback function triggered when the dropdown closes.
- containerClassName?: (isOpen: boolean) => string; - Function to return the class name for the container based on the open state.
- tabIndex?: number; - Sets the tab index for the dropdown.
- placement?: Placement; - Determines the placement of the dropdown (e.g., top, bottom, left, right).
- disabled?: boolean; - Disables the dropdown if set to true.
---
### Button Props
- buttonContent?: (isOpen: boolean) => React.ReactNode; - Function to render the content of the button based on the open state.
- buttonContainerClassName?: string; - Class name for the button container.
- buttonClassName?: string; - Class name for the button itself.
---
### Input Props
- disableSearch?: boolean; - Disables the search input if set to true.
- inputPlaceholder?: string; - Placeholder text for the search input.
- inputClassName?: string; - Class name for the search input.
- inputIcon?: React.ReactNode; - Icon to be displayed in the search input.
- inputContainerClassName?: string; - Class name for the search input container.
---
### Options Props
- keyExtractor: (option: TDropdownOption) => string; - Function to extract the key from each option.
- optionsContainerClassName?: string; - Class name for the options container.
- queryArray: string[]; - Array of strings to be used for querying the options.
- sortByKey: string; - Key to sort the options by.
- firstItem?: (optionValue: string) => boolean; - Function to determine if an option should be the first item.
- renderItem?: ({ value, selected }: { value: string; selected: boolean }) => React.ReactNode; - Function to render each option.
- loader?: React.ReactNode; - Loader element to be displayed while options are being loaded.
- disableSorting?: boolean; - Disables sorting of the options if set to true.
---
These properties offer extensive control over the dropdown's behavior and presentation, making it a highly versatile component suitable for various scenarios.
@@ -0,0 +1,38 @@
import React, { Fragment } from "react";
// headless ui
import { Combobox } from "@headlessui/react";
// helper
import { cn } from "../../../helpers";
import { IMultiSelectDropdownButton, ISingleSelectDropdownButton } from "../dropdown";
export const DropdownButton: React.FC<IMultiSelectDropdownButton | ISingleSelectDropdownButton> = (props) => {
const {
isOpen,
buttonContent,
buttonClassName,
buttonContainerClassName,
handleOnClick,
value,
setReferenceElement,
disabled,
} = props;
return (
<Combobox.Button as={Fragment}>
<button
ref={setReferenceElement}
type="button"
className={cn(
"clickable block h-full max-w-full outline-none",
{
"cursor-not-allowed text-custom-text-200": disabled,
"cursor-pointer": !disabled,
},
buttonContainerClassName
)}
onClick={handleOnClick}
>
{buttonContent ? <>{buttonContent(isOpen)}</> : <span className={cn("", buttonClassName)}>{value}</span>}
</button>
</Combobox.Button>
);
};
+4
View File
@@ -0,0 +1,4 @@
export * from "./input-search";
export * from "./button";
export * from "./options";
export * from "./loader";
@@ -0,0 +1,58 @@
import React, { FC, useEffect, useRef } from "react";
// headless ui
import { Combobox } from "@headlessui/react";
// icons
import { Search } from "lucide-react";
// helpers
import { cn } from "../../../helpers";
interface IInputSearch {
isOpen: boolean;
query: string;
updateQuery: (query: string) => void;
inputIcon?: React.ReactNode;
inputContainerClassName?: string;
inputClassName?: string;
inputPlaceholder?: string;
}
export const InputSearch: FC<IInputSearch> = (props) => {
const { isOpen, query, updateQuery, inputIcon, inputContainerClassName, inputClassName, inputPlaceholder } = props;
const inputRef = useRef<HTMLInputElement | null>(null);
const searchInputKeyDown = (e: React.KeyboardEvent<HTMLInputElement>) => {
if (query !== "" && e.key === "Escape") {
e.stopPropagation();
updateQuery("");
}
};
useEffect(() => {
if (isOpen) {
inputRef.current && inputRef.current.focus();
}
}, [isOpen]);
return (
<div
className={cn(
"flex items-center gap-1.5 rounded border border-custom-border-100 bg-custom-background-90 px-2",
inputContainerClassName
)}
>
{inputIcon ? <>{inputIcon}</> : <Search className="h-4 w-4 text-custom-text-300" aria-hidden="true" />}
<Combobox.Input
as="input"
ref={inputRef}
className={cn(
"w-full bg-transparent py-1 text-xs text-custom-text-200 placeholder:text-custom-text-400 focus:outline-none",
inputClassName
)}
value={query}
onChange={(e) => updateQuery(e.target.value)}
placeholder={inputPlaceholder ?? "Search"}
onKeyDown={searchInputKeyDown}
/>
</div>
);
};
@@ -0,0 +1,9 @@
import React from "react";
export const DropdownOptionsLoader = () => (
<div className="flex flex-col gap-1 animate-pulse">
{Array.from({ length: 6 }, (_, i) => (
<div key={i} className="flex h-[1.925rem] w-full rounded px-1 py-1.5 bg-custom-background-90" />
))}
</div>
);
@@ -0,0 +1,88 @@
import React from "react";
// headless ui
import { Combobox } from "@headlessui/react";
// icons
import { Check } from "lucide-react";
// components
import { DropdownOptionsLoader, InputSearch } from ".";
// helpers
import { cn } from "../../../helpers";
// types
import { IMultiSelectDropdownOptions, ISingleSelectDropdownOptions } from "../dropdown";
export const DropdownOptions: React.FC<IMultiSelectDropdownOptions | ISingleSelectDropdownOptions> = (props) => {
const {
isOpen,
query,
setQuery,
inputIcon,
inputPlaceholder,
inputClassName,
inputContainerClassName,
disableSearch,
keyExtractor,
options,
value,
renderItem,
loader,
} = props;
return (
<>
{!disableSearch && (
<InputSearch
isOpen={isOpen}
query={query}
updateQuery={(query) => setQuery(query)}
inputIcon={inputIcon}
inputPlaceholder={inputPlaceholder}
inputClassName={inputClassName}
inputContainerClassName={inputContainerClassName}
/>
)}
<div className="mt-2 max-h-48 space-y-1 overflow-y-scroll">
<>
{options ? (
options.length > 0 ? (
options?.map((option) => (
<Combobox.Option
key={keyExtractor(option)}
value={option.data[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",
{
"bg-custom-background-80": active,
"text-custom-text-100": selected,
"text-custom-text-200": !selected,
},
option.className && option.className({ active, selected })
)
}
>
{({ selected }) => (
<>
{renderItem ? (
<>{renderItem({ value: option.data[option.value], selected })}</>
) : (
<>
<span className="flex-grow truncate">{value}</span>
{selected && <Check className="h-3.5 w-3.5 flex-shrink-0" />}
</>
)}
</>
)}
</Combobox.Option>
))
) : (
<p className="px-1.5 py-1 italic text-custom-text-400">No matching results</p>
)
) : loader ? (
<> {loader} </>
) : (
<DropdownOptionsLoader />
)}
</>
</div>
</>
);
};
+94
View File
@@ -0,0 +1,94 @@
import { Placement } from "@popperjs/core";
export interface IDropdown {
// root props
onOpen?: () => void;
onClose?: () => void;
containerClassName?: (isOpen: boolean) => string;
tabIndex?: number;
placement?: Placement;
disabled?: boolean;
// button props
buttonContent?: (isOpen: boolean) => React.ReactNode;
buttonContainerClassName?: string;
buttonClassName?: string;
// input props
disableSearch?: boolean;
inputPlaceholder?: string;
inputClassName?: string;
inputIcon?: React.ReactNode;
inputContainerClassName?: string;
// options props
keyExtractor: (option: TDropdownOption) => string;
optionsContainerClassName?: string;
queryArray: string[];
sortByKey: string;
firstItem?: (optionValue: string) => boolean;
renderItem?: ({ value, selected }: { value: string; selected: boolean }) => React.ReactNode;
loader?: React.ReactNode;
disableSorting?: boolean;
}
export interface TDropdownOption {
data: any;
value: string;
className?: ({ active, selected }: { active: boolean; selected: boolean }) => string;
}
export interface IMultiSelectDropdown extends IDropdown {
value: string[];
onChange: (value: string[]) => void;
options: TDropdownOption[] | undefined;
}
export interface ISingleSelectDropdown extends IDropdown {
value: string;
onChange: (value: string) => void;
options: TDropdownOption[] | undefined;
}
export interface IDropdownButton {
isOpen: boolean;
buttonContent?: (isOpen: boolean) => React.ReactNode;
buttonClassName?: string;
buttonContainerClassName?: string;
handleOnClick: (e: React.MouseEvent<HTMLButtonElement, MouseEvent>) => void;
setReferenceElement: (element: HTMLButtonElement | null) => void;
disabled?: boolean;
}
export interface IMultiSelectDropdownButton extends IDropdownButton {
value: string[];
}
export interface ISingleSelectDropdownButton extends IDropdownButton {
value: string;
}
export interface IDropdownOptions {
isOpen: boolean;
query: string;
setQuery: (query: string) => void;
inputPlaceholder?: string;
inputClassName?: string;
inputIcon?: React.ReactNode;
inputContainerClassName?: string;
disableSearch?: boolean;
keyExtractor: (option: TDropdownOption) => string;
renderItem: (({ value, selected }: { value: string; selected: boolean }) => React.ReactNode) | undefined;
options: TDropdownOption[] | undefined;
loader?: React.ReactNode;
}
export interface IMultiSelectDropdownOptions extends IDropdownOptions {
value: string[];
}
export interface ISingleSelectDropdownOptions extends IDropdownOptions {
value: string;
}
+3
View File
@@ -0,0 +1,3 @@
export * from "./common";
export * from "./multi-select";
export * from "./single-select";
+167
View File
@@ -0,0 +1,167 @@
import React, { FC, useMemo, useRef, useState } from "react";
import sortBy from "lodash/sortBy";
// headless ui
import { Combobox } from "@headlessui/react";
// popper-js
import { usePopper } from "react-popper";
// components
import { DropdownButton } from "./common";
import { DropdownOptions } from "./common/options";
// hooks
import { useDropdownKeyPressed } from "../hooks/use-dropdown-key-pressed";
import useOutsideClickDetector from "../hooks/use-outside-click-detector";
// helper
import { cn } from "../../helpers";
// types
import { IMultiSelectDropdown } from "./dropdown";
export const MultiSelectDropdown: FC<IMultiSelectDropdown> = (props) => {
const {
value,
onChange,
options,
onOpen,
onClose,
containerClassName,
tabIndex,
placement,
disabled,
buttonContent,
buttonContainerClassName,
buttonClassName,
disableSearch,
inputPlaceholder,
inputClassName,
inputIcon,
inputContainerClassName,
keyExtractor,
optionsContainerClassName,
queryArray,
sortByKey,
firstItem,
renderItem,
loader = false,
disableSorting,
} = props;
// states
const [isOpen, setIsOpen] = useState(false);
const [query, setQuery] = useState("");
const [popperElement, setPopperElement] = useState<HTMLDivElement | null>(null);
// refs
const dropdownRef = useRef<HTMLDivElement | null>(null);
// popper-js refs
const [referenceElement, setReferenceElement] = useState<HTMLButtonElement | null>(null);
// popper-js init
const { styles, attributes } = usePopper(referenceElement, popperElement, {
placement: placement ?? "bottom-start",
modifiers: [
{
name: "preventOverflow",
options: {
padding: 12,
},
},
],
});
// handlers
const toggleDropdown = () => {
if (!isOpen) onOpen?.();
setIsOpen((prevIsOpen) => !prevIsOpen);
if (isOpen) onClose?.();
};
const handleOnClick = (e: React.MouseEvent<HTMLButtonElement, MouseEvent>) => {
e.stopPropagation();
e.preventDefault();
toggleDropdown();
};
const handleClose = () => {
if (!isOpen) return;
setIsOpen(false);
onClose?.();
setQuery?.("");
};
// options
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());
});
if (disableSorting) return filteredOptions;
return sortBy(filteredOptions, [
(option) => firstItem && firstItem(option.data[option.value]),
(option) => !(value ?? []).includes(option.data[option.value]),
() => sortByKey && sortByKey.toLowerCase(),
]);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [query, options]);
// hooks
const handleKeyDown = useDropdownKeyPressed(toggleDropdown, handleClose);
useOutsideClickDetector(dropdownRef, handleClose);
return (
<Combobox
as="div"
ref={dropdownRef}
value={value}
onChange={onChange}
className={cn("h-full", containerClassName)}
tabIndex={tabIndex}
multiple
onKeyDown={handleKeyDown}
disabled={disabled}
>
<DropdownButton
value={value}
isOpen={isOpen}
setReferenceElement={setReferenceElement}
handleOnClick={handleOnClick}
buttonContent={buttonContent}
buttonClassName={buttonClassName}
buttonContainerClassName={buttonContainerClassName}
disabled={disabled}
/>
{isOpen && (
<Combobox.Options className="fixed z-10" static>
<div
className={cn(
"my-1 w-48 rounded border-[0.5px] border-custom-border-300 bg-custom-background-100 px-2 py-2.5 text-xs shadow-custom-shadow-rg focus:outline-none",
optionsContainerClassName
)}
ref={setPopperElement}
style={styles.popper}
{...attributes.popper}
>
<DropdownOptions
isOpen={isOpen}
query={query}
setQuery={setQuery}
inputIcon={inputIcon}
inputPlaceholder={inputPlaceholder}
inputClassName={inputClassName}
inputContainerClassName={inputContainerClassName}
disableSearch={disableSearch}
keyExtractor={keyExtractor}
options={sortedOptions}
value={value}
renderItem={renderItem}
loader={loader}
/>
</div>
</Combobox.Options>
)}
</Combobox>
);
};
+166
View File
@@ -0,0 +1,166 @@
import React, { FC, useMemo, useRef, useState } from "react";
import sortBy from "lodash/sortBy";
// headless ui
import { Combobox } from "@headlessui/react";
// popper-js
import { usePopper } from "react-popper";
// components
import { DropdownButton } from "./common";
import { DropdownOptions } from "./common/options";
// hooks
import { useDropdownKeyPressed } from "../hooks/use-dropdown-key-pressed";
import useOutsideClickDetector from "../hooks/use-outside-click-detector";
// helper
import { cn } from "../../helpers";
// types
import { ISingleSelectDropdown } from "./dropdown";
export const Dropdown: FC<ISingleSelectDropdown> = (props) => {
const {
value,
onChange,
options,
onOpen,
onClose,
containerClassName,
tabIndex,
placement,
disabled,
buttonContent,
buttonContainerClassName,
buttonClassName,
disableSearch,
inputPlaceholder,
inputClassName,
inputIcon,
inputContainerClassName,
keyExtractor,
optionsContainerClassName,
queryArray,
sortByKey,
firstItem,
renderItem,
loader = false,
disableSorting,
} = props;
// states
const [isOpen, setIsOpen] = useState(false);
const [query, setQuery] = useState("");
const [popperElement, setPopperElement] = useState<HTMLDivElement | null>(null);
// refs
const dropdownRef = useRef<HTMLDivElement | null>(null);
// popper-js refs
const [referenceElement, setReferenceElement] = useState<HTMLButtonElement | null>(null);
// popper-js init
const { styles, attributes } = usePopper(referenceElement, popperElement, {
placement: placement ?? "bottom-start",
modifiers: [
{
name: "preventOverflow",
options: {
padding: 12,
},
},
],
});
// handlers
const toggleDropdown = () => {
if (!isOpen) onOpen?.();
setIsOpen((prevIsOpen) => !prevIsOpen);
if (isOpen) onClose?.();
};
const handleOnClick = (e: React.MouseEvent<HTMLButtonElement, MouseEvent>) => {
e.stopPropagation();
e.preventDefault();
toggleDropdown();
};
const handleClose = () => {
if (!isOpen) return;
setIsOpen(false);
onClose?.();
setQuery?.("");
};
// options
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());
});
if (disableSorting) return filteredOptions;
return sortBy(filteredOptions, [
(option) => firstItem && firstItem(option.data[option.value]),
(option) => !(value ?? []).includes(option.data[option.value]),
() => sortByKey && sortByKey.toLowerCase(),
]);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [query, options]);
// hooks
const handleKeyDown = useDropdownKeyPressed(toggleDropdown, handleClose);
useOutsideClickDetector(dropdownRef, handleClose);
return (
<Combobox
as="div"
ref={dropdownRef}
value={value}
onChange={onChange}
className={cn("h-full", containerClassName)}
tabIndex={tabIndex}
onKeyDown={handleKeyDown}
disabled={disabled}
>
<DropdownButton
value={value}
isOpen={isOpen}
setReferenceElement={setReferenceElement}
handleOnClick={handleOnClick}
buttonContent={buttonContent}
buttonClassName={buttonClassName}
buttonContainerClassName={buttonContainerClassName}
disabled={disabled}
/>
{isOpen && (
<Combobox.Options className="fixed z-10" static>
<div
className={cn(
"my-1 w-48 rounded border-[0.5px] border-custom-border-300 bg-custom-background-100 px-2 py-2.5 text-xs shadow-custom-shadow-rg focus:outline-none",
optionsContainerClassName
)}
ref={setPopperElement}
style={styles.popper}
{...attributes.popper}
>
<DropdownOptions
isOpen={isOpen}
query={query}
setQuery={setQuery}
inputIcon={inputIcon}
inputPlaceholder={inputPlaceholder}
inputClassName={inputClassName}
inputContainerClassName={inputContainerClassName}
disableSearch={disableSearch}
keyExtractor={keyExtractor}
options={sortedOptions}
value={value}
renderItem={renderItem}
loader={loader}
/>
</div>
</Combobox.Options>
)}
</Combobox>
);
};
@@ -0,0 +1,36 @@
import { useCallback } from "react";
type TUseDropdownKeyPressed = {
(
onEnterKeyDown: () => void,
onEscKeyDown: () => void,
stopPropagation?: boolean
): (event: React.KeyboardEvent<HTMLElement>) => void;
};
export const useDropdownKeyPressed: TUseDropdownKeyPressed = (onEnterKeyDown, onEscKeyDown, stopPropagation = true) => {
const stopEventPropagation = useCallback(
(event: React.KeyboardEvent<HTMLElement>) => {
if (stopPropagation) {
event.stopPropagation();
event.preventDefault();
}
},
[stopPropagation]
);
const handleKeyDown = useCallback(
(event: React.KeyboardEvent<HTMLElement>) => {
if (event.key === "Enter") {
stopEventPropagation(event);
onEnterKeyDown();
} else if (event.key === "Escape") {
stopEventPropagation(event);
onEscKeyDown();
} else if (event.key === "Tab") onEscKeyDown();
},
[onEnterKeyDown, onEscKeyDown, stopEventPropagation]
);
return handleKeyDown;
};
+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";
+3 -1
View File
@@ -4,6 +4,7 @@ export * from "./badge";
export * from "./button";
export * from "./emoji";
export * from "./dropdowns";
export * from "./dropdown";
export * from "./form-fields";
export * from "./icons";
export * from "./progress";
@@ -13,4 +14,5 @@ export * from "./loader";
export * from "./control-link";
export * from "./toast";
export * from "./drag-handle";
export * from "./drop-indicator";
export * from "./drop-indicator";
export * from "./sortable";
+62
View File
@@ -0,0 +1,62 @@
import React, { useEffect, useRef, useState } from "react";
import { combine } from "@atlaskit/pragmatic-drag-and-drop/combine";
import { draggable, dropTargetForElements } from "@atlaskit/pragmatic-drag-and-drop/element/adapter";
import { isEqual } from "lodash";
import { cn } from "../../helpers";
import { attachClosestEdge, extractClosestEdge } from "@atlaskit/pragmatic-drag-and-drop-hitbox/closest-edge";
import { DropIndicator } from "../drop-indicator";
type Props = {
children: React.ReactNode;
data: any; //@todo make this generic
className?: string;
};
const Draggable = ({ children, data, className }: Props) => {
const ref = useRef<HTMLDivElement>(null);
const [dragging, setDragging] = useState<boolean>(false); // NEW
const [isDraggedOver, setIsDraggedOver] = useState(false);
const [closestEdge, setClosestEdge] = useState<string | null>(null);
useEffect(() => {
const el = ref.current;
if (el) {
combine(
draggable({
element: el,
onDragStart: () => setDragging(true), // NEW
onDrop: () => setDragging(false), // NEW
getInitialData: () => data,
}),
dropTargetForElements({
element: el,
onDragEnter: (args) => {
setIsDraggedOver(true);
setClosestEdge(extractClosestEdge(args.self.data));
},
onDragLeave: () => setIsDraggedOver(false),
onDrop: () => {
setIsDraggedOver(false);
},
canDrop: ({ source }) => !isEqual(source.data, data) && source.data.__uuid__ === data.__uuid__,
getData: ({ input, element }) =>
attachClosestEdge(data, {
input,
element,
allowedEdges: ["top", "bottom"],
}),
})
);
}
}, [data]);
return (
<div ref={ref} className={cn(dragging && "opacity-25", className)}>
{<DropIndicator isVisible={isDraggedOver && closestEdge === "top"} />}
{children}
{<DropIndicator isVisible={isDraggedOver && closestEdge === "bottom"} />}
</div>
);
};
export { Draggable };
+2
View File
@@ -0,0 +1,2 @@
export * from "./sortable";
export * from "./draggable";
@@ -0,0 +1,32 @@
import type { Meta, StoryObj } from "@storybook/react";
import React from "react";
import { Draggable } from "./draggable";
import { Sortable } from "./sortable";
const meta: Meta<typeof Sortable> = {
title: "Sortable",
component: Sortable,
};
export default meta;
type Story = StoryObj<typeof Sortable>;
const data = [
{ id: "1", name: "John Doe" },
{ id: "2", name: "Jane Doe 2" },
{ id: "3", name: "Alice" },
{ id: "4", name: "Bob" },
{ id: "5", name: "Charlie" },
];
export const Default: Story = {
args: {
data,
render: (item: any) => (
// <Draggable data={item} className="rounded-lg">
<div className="border ">{item.name}</div>
// </Draggable>
),
onChange: (data) => console.log(data.map(({ id }) => id)),
keyExtractor: (item: any) => item.id,
},
};
+79
View File
@@ -0,0 +1,79 @@
import React, { Fragment, useEffect, useMemo } from "react";
import { monitorForElements } from "@atlaskit/pragmatic-drag-and-drop/element/adapter";
import { Draggable } from "./draggable";
type Props<T> = {
data: T[];
render: (item: T, index: number) => React.ReactNode;
onChange: (data: T[]) => void;
keyExtractor: (item: T, index: number) => string;
containerClassName?: string;
id: string;
};
const moveItem = <T,>(
data: T[],
source: T,
destination: T & Record<symbol, string>,
keyExtractor: (item: T, index: number) => string
) => {
const sourceIndex = data.indexOf(source);
if (sourceIndex === -1) return data;
const destinationIndex = data.findIndex((item, index) => keyExtractor(item, index) === keyExtractor(destination, 0));
if (destinationIndex === -1) return data;
const symbolKey = Reflect.ownKeys(destination).find((key) => key.toString() === "Symbol(closestEdge)");
const position = symbolKey ? destination[symbolKey as symbol] : "bottom"; // Add 'as symbol' to cast symbolKey to symbol
const newData = [...data];
const [movedItem] = newData.splice(sourceIndex, 1);
let adjustedDestinationIndex = destinationIndex;
if (position === "bottom") {
adjustedDestinationIndex++;
}
// Prevent moving item out of bounds
if (adjustedDestinationIndex > newData.length) {
adjustedDestinationIndex = newData.length;
}
newData.splice(adjustedDestinationIndex, 0, movedItem);
return newData;
};
export const Sortable = <T,>({ data, render, onChange, keyExtractor, containerClassName, id }: Props<T>) => {
useEffect(() => {
const unsubscribe = monitorForElements({
onDrop({ source, location }) {
const destination = location?.current?.dropTargets[0];
if (!destination) return;
onChange(moveItem(data, source.data as T, destination.data as T & { closestEdge: string }, keyExtractor));
},
});
// Clean up the subscription on unmount
return () => {
if (unsubscribe) unsubscribe();
};
}, [data, keyExtractor, onChange]);
const enhancedData = useMemo(() => {
const uuid = id ? id : Math.random().toString(36).substring(7);
return data.map((item) => ({ ...item, __uuid__: uuid }));
}, [data, id]);
return (
<>
{enhancedData.map((item, index) => (
<Draggable key={keyExtractor(item, index)} data={item} className={containerClassName}>
<Fragment>{render(item, index)} </Fragment>
</Draggable>
))}
</>
);
};
export default Sortable;
+1
View File
@@ -0,0 +1 @@
export * from "./sub-heading";
@@ -0,0 +1,15 @@
import React from "react";
import { cn } from "../../helpers";
type Props = {
children: React.ReactNode;
className?: string;
noMargin?: boolean;
};
const SubHeading = ({ children, className, noMargin }: Props) => (
<h3 className={cn("text-xl font-medium text-custom-text-200 block leading-7", !noMargin && "mb-2", className)}>
{children}
</h3>
);
export { SubHeading };
@@ -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) => {
@@ -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
View File
@@ -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>
</>
);
+18
View File
@@ -52,10 +52,13 @@ export enum EAuthenticationErrorCodes {
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",
@@ -220,6 +223,10 @@ const errorCodeMessages: {
},
// Oauth
[EAuthenticationErrorCodes.OAUTH_NOT_CONFIGURED]: {
title: `OAuth not configured`,
message: () => `OAuth not configured. Please contact your administrator.`,
},
[EAuthenticationErrorCodes.GOOGLE_NOT_CONFIGURED]: {
title: `Google not configured`,
message: () => `Google not configured. Please contact your administrator.`,
@@ -228,6 +235,10 @@ const errorCodeMessages: {
title: `GitHub not configured`,
message: () => `GitHub not configured. Please contact your administrator.`,
},
[EAuthenticationErrorCodes.GITLAB_NOT_CONFIGURED]: {
title: `GitLab not configured`,
message: () => `GitLab not configured. Please contact your administrator.`,
},
[EAuthenticationErrorCodes.GOOGLE_OAUTH_PROVIDER_ERROR]: {
title: `Google OAuth provider error`,
message: () => `Google OAuth provider error. Please try again.`,
@@ -236,6 +247,10 @@ const errorCodeMessages: {
title: `GitHub OAuth provider error`,
message: () => `GitHub OAuth provider error. Please try again.`,
},
[EAuthenticationErrorCodes.GITLAB_OAUTH_PROVIDER_ERROR]: {
title: `GitLab OAuth provider error`,
message: () => `GitLab OAuth provider error. Please try again.`,
},
// Reset Password
[EAuthenticationErrorCodes.INVALID_PASSWORD_TOKEN]: {
@@ -347,10 +362,13 @@ export const authErrorHandler = (
EAuthenticationErrorCodes.EXPIRED_MAGIC_CODE_SIGN_UP,
EAuthenticationErrorCodes.EMAIL_CODE_ATTEMPT_EXHAUSTED_SIGN_IN,
EAuthenticationErrorCodes.EMAIL_CODE_ATTEMPT_EXHAUSTED_SIGN_UP,
EAuthenticationErrorCodes.OAUTH_NOT_CONFIGURED,
EAuthenticationErrorCodes.GOOGLE_NOT_CONFIGURED,
EAuthenticationErrorCodes.GITHUB_NOT_CONFIGURED,
EAuthenticationErrorCodes.GITLAB_NOT_CONFIGURED,
EAuthenticationErrorCodes.GOOGLE_OAUTH_PROVIDER_ERROR,
EAuthenticationErrorCodes.GITHUB_OAUTH_PROVIDER_ERROR,
EAuthenticationErrorCodes.GITLAB_OAUTH_PROVIDER_ERROR,
EAuthenticationErrorCodes.INVALID_PASSWORD_TOKEN,
EAuthenticationErrorCodes.EXPIRED_PASSWORD_TOKEN,
EAuthenticationErrorCodes.INCORRECT_OLD_PASSWORD,
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "space",
"version": "0.20.0",
"version": "0.21.0",
"private": true,
"scripts": {
"dev": "turbo run develop",
+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

+1
View File
@@ -4,6 +4,7 @@ export interface IAppConfig {
google_client_id: string | null;
github_app_name: string | null;
github_client_id: string | null;
gitlab_client_id: string | null;
magic_login: boolean;
slack_client_id: string | null;
posthog_api_key: string | null;
@@ -0,0 +1,36 @@
import { FC } from "react";
import { useRouter } from "next/router";
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 { query } = useRouter();
const { next_path } = query;
const { text } = props;
// hooks
const { resolvedTheme } = useTheme();
const handleSignIn = () => {
window.location.assign(`${API_BASE_URL}/auth/gitlab/${next_path ? `?next_path=${next_path}` : ``}`);
};
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 bg-onboarding-background-200 hover:bg-onboarding-background-300 ${
resolvedTheme === "dark" ? "border-[#43484F]" : "border-[#D9E4FF]"
}`}
onClick={handleSignIn}
>
<Image src={GitlabLogo} height={20} width={20} alt="GitLab Logo" />
{text}
</button>
);
};
+1
View File
@@ -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";
// components
import { GithubOAuthButton, GoogleOAuthButton } from "@/components/account";
import { GithubOAuthButton, GitlabOAuthButton, GoogleOAuthButton } from "@/components/account";
// hooks
import { useInstance } from "@/hooks/store";
@@ -12,7 +12,7 @@ export const OAuthOptions: React.FC<TOAuthOptionProps> = observer(() => {
// hooks
const { config } = useInstance();
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;
if (!isOAuthEnabled) return null;
@@ -30,6 +30,7 @@ export const OAuthOptions: React.FC<TOAuthOptionProps> = observer(() => {
</div>
)}
{config?.is_github_enabled && <GithubOAuthButton text="Continue with Github" />}
{config?.is_gitlab_enabled && <GitlabOAuthButton text="Continue with GitLab" />}
</div>
</>
);
@@ -210,11 +210,7 @@ export const GptAssistantPopover: React.FC<Props> = (props) => {
{response !== "" && (
<div className="page-block-section max-h-[8rem] text-sm">
Response:
<RichTextReadOnlyEditor
initialValue={`<p>${response}</p>`}
containerClassName={response ? "-mx-3 -my-3" : ""}
ref={responseRef}
/>
<RichTextReadOnlyEditor initialValue={`<p>${response}</p>`} ref={responseRef} />
</div>
)}
{invalidResponse && (
@@ -18,6 +18,8 @@ export const MultipleSelectEntityAction: React.FC<Props> = (props) => {
// derived values
const isSelected = selectionHelpers.getIsEntitySelected(id);
if (selectionHelpers.isSelectionDisabled) return null;
return (
<Checkbox
className={cn("!outline-none size-3.5", className)}
@@ -17,6 +17,8 @@ export const MultipleSelectGroupAction: React.FC<Props> = (props) => {
// derived values
const groupSelectionStatus = selectionHelpers.isGroupSelected(groupID);
if (selectionHelpers.isSelectionDisabled) return null;
return (
<Checkbox
className={cn("size-3.5 !outline-none", className)}
@@ -5,14 +5,16 @@ import { TSelectionHelper, useMultipleSelect } from "@/hooks/use-multiple-select
type Props = {
children: (helpers: TSelectionHelper) => React.ReactNode;
containerRef: React.MutableRefObject<HTMLElement | null>;
disabled?: boolean;
entities: Record<string, string[]>; // { groupID: entityIds[] }
};
export const MultipleSelectGroup: React.FC<Props> = observer((props) => {
const { children, containerRef, entities } = props;
const { children, containerRef, disabled = false, entities } = props;
const helpers = useMultipleSelect({
containerRef,
disabled,
entities,
});
@@ -156,6 +156,8 @@ export const CycleMobileHeader = () => {
layoutDisplayFiltersOptions={
activeLayout ? ISSUE_DISPLAY_FILTERS_BY_LAYOUT.issues[activeLayout] : undefined
}
displayFilters={issueFilters?.displayFilters ?? {}}
handleDisplayFiltersUpdate={handleDisplayFilters}
labels={projectLabels}
memberIds={projectMemberIds ?? undefined}
states={projectStates}
+15 -7
View File
@@ -1,15 +1,16 @@
import { observer } from "mobx-react";
// hooks
// components
// helpers
import { cn } from "@/helpers/common.helper";
import { getDate, renderFormattedPayloadDate } from "@/helpers/date-time.helper";
// hooks
import { useIssueDetail } from "@/hooks/store";
// types
import { TSelectionHelper } from "@/hooks/use-multiple-select";
// constants
import { BLOCK_HEIGHT } from "../constants";
// components
import { ChartAddBlock, ChartDraggable } from "../helpers";
import { useGanttChart } from "../hooks";
// types
import { IBlockUpdateData, IGanttBlock } from "../types";
type Props = {
@@ -21,6 +22,7 @@ type Props = {
enableBlockMove: boolean;
enableAddBlock: boolean;
ganttContainerRef: React.RefObject<HTMLDivElement>;
selectionHelpers: TSelectionHelper;
};
export const GanttChartBlock: React.FC<Props> = observer((props) => {
@@ -33,6 +35,7 @@ export const GanttChartBlock: React.FC<Props> = observer((props) => {
enableBlockMove,
enableAddBlock,
ganttContainerRef,
selectionHelpers,
} = props;
// store hooks
const { updateActiveBlockId, isBlockActive } = useGanttChart();
@@ -70,6 +73,10 @@ export const GanttChartBlock: React.FC<Props> = observer((props) => {
});
};
const isBlockSelected = selectionHelpers.getIsEntitySelected(block.id);
const isBlockFocused = selectionHelpers.getIsEntityActive(block.id);
const isBlockHoveredOn = isBlockActive(block.id);
return (
<div
key={`block-${block.id}`}
@@ -80,10 +87,11 @@ export const GanttChartBlock: React.FC<Props> = observer((props) => {
>
<div
className={cn("relative h-full", {
"bg-custom-background-80": isBlockActive(block.id),
"rounded-l border border-r-0 border-custom-primary-70 hover:border-custom-primary-70": getIsIssuePeeked(
block.data.id
),
"rounded-l border border-r-0 border-custom-primary-70": getIsIssuePeeked(block.data.id),
"bg-custom-background-90": isBlockHoveredOn,
"bg-custom-primary-100/5 hover:bg-custom-primary-100/10": isBlockSelected,
"bg-custom-primary-100/10": isBlockSelected && isBlockHoveredOn,
"border border-r-0 border-custom-border-400": isBlockFocused,
})}
onMouseEnter={() => updateActiveBlockId(block.id)}
onMouseLeave={() => updateActiveBlockId(null)}
@@ -1,10 +1,12 @@
import { FC } from "react";
// components
import { HEADER_HEIGHT } from "../constants";
import { IBlockUpdateData, IGanttBlock } from "../types";
import { GanttChartBlock } from "./block";
// types
// hooks
import { TSelectionHelper } from "@/hooks/use-multiple-select";
// constants
import { HEADER_HEIGHT } from "../constants";
// types
import { IBlockUpdateData, IGanttBlock } from "../types";
// components
import { GanttChartBlock } from "./block";
export type GanttChartBlocksProps = {
itemsContainerWidth: number;
@@ -17,6 +19,7 @@ export type GanttChartBlocksProps = {
enableAddBlock: boolean;
ganttContainerRef: React.RefObject<HTMLDivElement>;
showAllBlocks: boolean;
selectionHelpers: TSelectionHelper;
};
export const GanttChartBlocksList: FC<GanttChartBlocksProps> = (props) => {
@@ -31,6 +34,7 @@ export const GanttChartBlocksList: FC<GanttChartBlocksProps> = (props) => {
enableAddBlock,
ganttContainerRef,
showAllBlocks,
selectionHelpers,
} = props;
return (
@@ -56,6 +60,7 @@ export const GanttChartBlocksList: FC<GanttChartBlocksProps> = (props) => {
enableBlockMove={enableBlockMove}
enableAddBlock={enableAddBlock}
ganttContainerRef={ganttContainerRef}
selectionHelpers={selectionHelpers}
/>
);
})}
@@ -2,8 +2,8 @@ import { useEffect, useRef } from "react";
import { combine } from "@atlaskit/pragmatic-drag-and-drop/combine";
import { autoScrollForElements } from "@atlaskit/pragmatic-drag-and-drop-auto-scroll/element";
import { observer } from "mobx-react";
// hooks
// components
import { MultipleSelectGroup } from "@/components/core";
import {
BiWeekChartView,
DayChartView,
@@ -18,8 +18,12 @@ import {
WeekChartView,
YearChartView,
} from "@/components/gantt-chart";
import { IssueBulkOperationsRoot } from "@/components/issues";
// helpers
import { cn } from "@/helpers/common.helper";
// constants
import { GANTT_SELECT_GROUP } from "../constants";
// hooks
import { useGanttChart } from "../hooks/use-gantt-chart";
type Props = {
@@ -33,6 +37,7 @@ type Props = {
enableBlockRightResize: boolean;
enableReorder: boolean;
enableAddBlock: boolean;
enableSelection: boolean;
itemsContainerWidth: number;
showAllBlocks: boolean;
sidebarToRender: (props: any) => React.ReactNode;
@@ -53,6 +58,7 @@ export const GanttChartMainContent: React.FC<Props> = observer((props) => {
enableBlockRightResize,
enableReorder,
enableAddBlock,
enableSelection,
itemsContainerWidth,
showAllBlocks,
sidebarToRender,
@@ -107,43 +113,59 @@ export const GanttChartMainContent: React.FC<Props> = observer((props) => {
const ActiveChartView = CHART_VIEW_COMPONENTS[currentView];
return (
<div
// DO NOT REMOVE THE ID
id="gantt-container"
className={cn(
"h-full w-full overflow-auto vertical-scrollbar horizontal-scrollbar scrollbar-lg flex border-t-[0.5px] border-custom-border-200",
{
"mb-8": bottomSpacing,
}
)}
ref={ganttContainerRef}
onScroll={onScroll}
<MultipleSelectGroup
containerRef={ganttContainerRef}
entities={{
[GANTT_SELECT_GROUP]: chartBlocks?.map((block) => block.id) ?? [],
}}
disabled
>
<GanttChartSidebar
blocks={blocks}
blockUpdateHandler={blockUpdateHandler}
enableReorder={enableReorder}
sidebarToRender={sidebarToRender}
title={title}
quickAdd={quickAdd}
/>
<div className="relative min-h-full h-max flex-shrink-0 flex-grow">
<ActiveChartView />
{currentViewData && (
<GanttChartBlocksList
itemsContainerWidth={itemsContainerWidth}
blocks={chartBlocks}
blockToRender={blockToRender}
blockUpdateHandler={blockUpdateHandler}
enableBlockLeftResize={enableBlockLeftResize}
enableBlockRightResize={enableBlockRightResize}
enableBlockMove={enableBlockMove}
enableAddBlock={enableAddBlock}
ganttContainerRef={ganttContainerRef}
showAllBlocks={showAllBlocks}
/>
)}
</div>
</div>
{(helpers) => (
<>
<div
// DO NOT REMOVE THE ID
id="gantt-container"
className={cn(
"h-full w-full overflow-auto vertical-scrollbar horizontal-scrollbar scrollbar-lg flex border-t-[0.5px] border-custom-border-200",
{
"mb-8": bottomSpacing,
}
)}
ref={ganttContainerRef}
onScroll={onScroll}
>
<GanttChartSidebar
blocks={blocks}
blockUpdateHandler={blockUpdateHandler}
enableReorder={enableReorder}
enableSelection={enableSelection}
sidebarToRender={sidebarToRender}
title={title}
quickAdd={quickAdd}
selectionHelpers={helpers}
/>
<div className="relative min-h-full h-max flex-shrink-0 flex-grow">
<ActiveChartView />
{currentViewData && (
<GanttChartBlocksList
itemsContainerWidth={itemsContainerWidth}
blocks={chartBlocks}
blockToRender={blockToRender}
blockUpdateHandler={blockUpdateHandler}
enableBlockLeftResize={enableBlockLeftResize}
enableBlockRightResize={enableBlockRightResize}
enableBlockMove={enableBlockMove}
enableAddBlock={enableAddBlock}
ganttContainerRef={ganttContainerRef}
showAllBlocks={showAllBlocks}
selectionHelpers={helpers}
/>
)}
</div>
</div>
<IssueBulkOperationsRoot selectionHelpers={helpers} />
</>
)}
</MultipleSelectGroup>
);
});
@@ -32,6 +32,7 @@ type ChartViewRootProps = {
enableBlockMove: boolean;
enableReorder: boolean;
enableAddBlock: boolean;
enableSelection: boolean;
bottomSpacing: boolean;
showAllBlocks: boolean;
quickAdd?: React.JSX.Element | undefined;
@@ -51,6 +52,7 @@ export const ChartViewRoot: FC<ChartViewRootProps> = observer((props) => {
enableBlockMove,
enableReorder,
enableAddBlock,
enableSelection,
bottomSpacing,
showAllBlocks,
quickAdd,
@@ -184,6 +186,7 @@ export const ChartViewRoot: FC<ChartViewRootProps> = observer((props) => {
enableBlockRightResize={enableBlockRightResize}
enableReorder={enableReorder}
enableAddBlock={enableAddBlock}
enableSelection={enableSelection}
itemsContainerWidth={itemsContainerWidth}
showAllBlocks={showAllBlocks}
sidebarToRender={sidebarToRender}

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