Compare commits

...

5 Commits

Author SHA1 Message Date
sriram veeraghanta
11b533c8be chore: implementing new combo box for member select 2023-11-14 19:03:59 +05:30
sriram veeraghanta
ae077d06e8 fix: adding loading screen on the home page. 2023-11-14 12:30:21 +05:30
Nikhil
b31041726b
dev: create bucket through application (#2720) 2023-11-13 15:57:19 +05:30
Prateek Shourya
e6f947ad90
style: ui improvements and bug fixes (#2758)
* style: add transition to favorite projects dropdown.

* style: update project integration settings borders.

* style: fix text overflow issue in project views.

* fix: issue with non-functional cancel button in leave project modal.
2023-11-13 14:42:45 +05:30
Dakshesh Jain
7963993171
fix: workspace settings bugs (#2743)
* fix: double layout in exports

* fix: typo in jira email address section

* fix: workspace members not mutating

* fix: removed un-used variable

* fix: workspace members can't be filtered using email

* fix: autocomplete in workspace delete

* fix: autocomplete in project delete modal

* fix: update member function in store

* fix: sidebar link not active when in github/jira

* style: margin top & icon inconsistency

* fix: typo in create workspace

* fix: workspace leave flow

* fix: redirection to delete issue

* fix: autocomplete off in jira api token

* refactor: reduced api call, added optional chaining & removed variable with low scope
2023-11-13 13:34:05 +05:30
26 changed files with 316 additions and 307 deletions

View File

@ -0,0 +1,57 @@
import os, sys
import boto3
from botocore.exceptions import ClientError
sys.path.append("/code")
os.environ.setdefault("DJANGO_SETTINGS_MODULE", "plane.settings.production")
import django
django.setup()
def create_bucket():
try:
from django.conf import settings
# Create a session using the credentials from Django settings
session = boto3.session.Session(
aws_access_key_id=settings.AWS_ACCESS_KEY_ID,
aws_secret_access_key=settings.AWS_SECRET_ACCESS_KEY,
)
# Create an S3 client using the session
s3_client = session.client('s3', endpoint_url=settings.AWS_S3_ENDPOINT_URL)
bucket_name = settings.AWS_STORAGE_BUCKET_NAME
print("Checking bucket...")
# Check if the bucket exists
s3_client.head_bucket(Bucket=bucket_name)
# If head_bucket does not raise an exception, the bucket exists
print(f"Bucket '{bucket_name}' already exists.")
except ClientError as e:
error_code = int(e.response['Error']['Code'])
bucket_name = settings.AWS_STORAGE_BUCKET_NAME
if error_code == 404:
# Bucket does not exist, create it
print(f"Bucket '{bucket_name}' does not exist. Creating bucket...")
try:
s3_client.create_bucket(Bucket=bucket_name)
print(f"Bucket '{bucket_name}' created successfully.")
except ClientError as create_error:
print(f"Failed to create bucket: {create_error}")
elif error_code == 403:
# Access to the bucket is forbidden
print(f"Access to the bucket '{bucket_name}' is forbidden. Check permissions.")
else:
# Another ClientError occurred
print(f"Failed to check bucket: {e}")
except Exception as ex:
# Handle any other exception
print(f"An error occurred: {ex}")
if __name__ == "__main__":
create_bucket()

View File

@ -5,5 +5,7 @@ python manage.py migrate
# Create a Default User # Create a Default User
python bin/user_script.py python bin/user_script.py
# Create the default bucket
python bin/bucket_script.py
exec gunicorn -w $GUNICORN_WORKERS -k uvicorn.workers.UvicornWorker plane.asgi:application --bind 0.0.0.0:8000 --max-requests 1200 --max-requests-jitter 1000 --access-logfile - exec gunicorn -w $GUNICORN_WORKERS -k uvicorn.workers.UvicornWorker plane.asgi:application --bind 0.0.0.0:8000 --max-requests 1200 --max-requests-jitter 1000 --access-logfile -

View File

@ -42,9 +42,12 @@ const IssueLink = ({ activity }: { activity: IIssueActivity }) => {
return ( return (
<Tooltip tooltipContent={activity.issue_detail ? activity.issue_detail.name : "This issue has been deleted"}> <Tooltip tooltipContent={activity.issue_detail ? activity.issue_detail.name : "This issue has been deleted"}>
<a <a
href={`/${workspaceSlug}/projects/${activity.project}/issues/${activity.issue}`} aria-disabled={activity.issue === null}
target="_blank" href={`${
rel="noopener noreferrer" activity.issue_detail ? `/${workspaceSlug}/projects/${activity.project}/issues/${activity.issue}` : "#"
}`}
target={activity.issue === null ? "_self" : "_blank"}
rel={activity.issue === null ? "" : "noopener noreferrer"}
className="font-medium text-custom-text-100 inline-flex items-center gap-1 hover:underline" className="font-medium text-custom-text-100 inline-flex items-center gap-1 hover:underline"
> >
{activity.issue_detail ? `${activity.project_detail.identifier}-${activity.issue_detail.sequence_id}` : "Issue"} {activity.issue_detail ? `${activity.project_detail.identifier}-${activity.issue_detail.sequence_id}` : "Issue"}

View File

@ -163,7 +163,7 @@ export const GithubImporterRoot: React.FC<Props> = ({ user }) => {
return ( return (
<form onSubmit={handleSubmit(createGithubImporterService)}> <form onSubmit={handleSubmit(createGithubImporterService)}>
<div className="space-y-2"> <div className="space-y-2 mt-4">
<Link href={`/${workspaceSlug}/settings/imports`}> <Link href={`/${workspaceSlug}/settings/imports`}>
<div className="inline-flex cursor-pointer items-center gap-2 text-sm font-medium text-custom-text-200 hover:text-custom-text-100"> <div className="inline-flex cursor-pointer items-center gap-2 text-sm font-medium text-custom-text-200 hover:text-custom-text-100">
<ArrowLeft className="h-3 w-3" /> <ArrowLeft className="h-3 w-3" />
@ -191,9 +191,7 @@ export const GithubImporterRoot: React.FC<Props> = ({ user }) => {
}`} }`}
> >
<integration.icon <integration.icon
width="18px" className={`w-5 h-5 ${index <= activeIntegrationState() ? "text-white" : "text-custom-text-400"}`}
height="18px"
color={index <= activeIntegrationState() ? "#ffffff" : "#d1d5db"}
/> />
</div> </div>
{index < integrationWorkflowData.length - 1 && ( {index < integrationWorkflowData.length - 1 && (

View File

@ -56,6 +56,7 @@ export const JiraGetImportDetail: React.FC = observer(() => {
ref={ref} ref={ref}
placeholder="XXXXXXXX" placeholder="XXXXXXXX"
className="w-full" className="w-full"
autoComplete="off"
/> />
)} )}
/> />
@ -94,7 +95,7 @@ export const JiraGetImportDetail: React.FC = observer(() => {
<div className="grid grid-cols-1 gap-10 md:grid-cols-2"> <div className="grid grid-cols-1 gap-10 md:grid-cols-2">
<div className="col-span-1"> <div className="col-span-1">
<h3 className="font-semibold">Jira Email Address</h3> <h3 className="font-semibold">Jira Email Address</h3>
<p className="text-sm text-custom-text-200">Enter the Gmail account that you use in Jira account</p> <p className="text-sm text-custom-text-200">Enter the Email account that you use in Jira account</p>
</div> </div>
<div className="col-span-1"> <div className="col-span-1">
<Controller <Controller

View File

@ -5,7 +5,7 @@ import { useRouter } from "next/router";
import { mutate } from "swr"; import { mutate } from "swr";
import { FormProvider, useForm } from "react-hook-form"; import { FormProvider, useForm } from "react-hook-form";
// icons // icons
import { ArrowLeft, Check, List, Settings } from "lucide-react"; import { ArrowLeft, Check, List, Settings, Users2 } from "lucide-react";
// services // services
import { JiraImporterService } from "services/integrations"; import { JiraImporterService } from "services/integrations";
// fetch keys // fetch keys
@ -98,7 +98,7 @@ export const JiraImporterRoot: React.FC<Props> = ({ user }) => {
}; };
return ( return (
<div className="flex h-full flex-col space-y-2"> <div className="flex h-full flex-col space-y-2 mt-4">
<Link href={`/${workspaceSlug}/settings/imports`}> <Link href={`/${workspaceSlug}/settings/imports`}>
<div className="inline-flex cursor-pointer items-center gap-2 text-sm font-medium text-custom-text-200 hover:text-custom-text-100"> <div className="inline-flex cursor-pointer items-center gap-2 text-sm font-medium text-custom-text-200 hover:text-custom-text-100">
<div> <div>
@ -136,9 +136,7 @@ export const JiraImporterRoot: React.FC<Props> = ({ user }) => {
}`} }`}
> >
<integration.icon <integration.icon
width="18px" className={`w-5 h-5 ${index <= activeIntegrationState() ? "text-white" : "text-custom-text-400"}`}
height="18px"
color={index <= activeIntegrationState() ? "#ffffff" : "#d1d5db"}
/> />
</button> </button>
{index < integrationWorkflowData.length - 1 && ( {index < integrationWorkflowData.length - 1 && (

View File

@ -77,9 +77,14 @@ export const SignInView = observer(() => {
); );
const mutateUserInfo = useCallback(() => { const mutateUserInfo = useCallback(() => {
fetchCurrentUser().then((user) => { setLoading(true);
handleLoginRedirection(user); fetchCurrentUser()
}); .then((user) => {
handleLoginRedirection(user);
})
.catch(() => {
setLoading(false);
});
}, [fetchCurrentUser, handleLoginRedirection]); }, [fetchCurrentUser, handleLoginRedirection]);
useEffect(() => { useEffect(() => {

View File

@ -139,6 +139,7 @@ export const DeleteProjectModal: React.FC<DeleteProjectModal> = (props) => {
hasError={Boolean(errors.projectName)} hasError={Boolean(errors.projectName)}
placeholder="Project name" placeholder="Project name"
className="mt-2 w-full" className="mt-2 w-full"
autoComplete="off"
/> />
)} )}
/> />
@ -162,6 +163,7 @@ export const DeleteProjectModal: React.FC<DeleteProjectModal> = (props) => {
hasError={Boolean(errors.confirmDelete)} hasError={Boolean(errors.confirmDelete)}
placeholder="Enter 'delete my project'" placeholder="Enter 'delete my project'"
className="mt-2 w-full" className="mt-2 w-full"
autoComplete="off"
/> />
)} )}
/> />

View File

@ -9,7 +9,7 @@ export * from "./form";
export * from "./join-project-modal"; export * from "./join-project-modal";
export * from "./leave-project-modal"; export * from "./leave-project-modal";
export * from "./member-select"; export * from "./member-select";
export * from "./members-select"; export * from "./members-autocomplete";
export * from "./priority-select"; export * from "./priority-select";
export * from "./sidebar-list-item"; export * from "./sidebar-list-item";
export * from "./sidebar-list"; export * from "./sidebar-list";
@ -17,5 +17,5 @@ export * from "./integration-card";
export * from "./member-list"; export * from "./member-list";
export * from "./member-list-item"; export * from "./member-list-item";
export * from "./project-settings-member-defaults"; export * from "./project-settings-member-defaults";
export * from "./send-project-invitation-modal"; export * from "./project-member-invite-modal";
export * from "./confirm-project-member-remove"; export * from "./confirm-project-member-remove";

View File

@ -91,7 +91,7 @@ export const IntegrationCard: React.FC<Props> = ({ integration }) => {
return ( return (
<> <>
{integration && ( {integration && (
<div className="flex items-center justify-between gap-2 border-b border-custom-border-200 bg-custom-background-100 px-4 py-6"> <div className="flex items-center justify-between gap-2 border-b border-custom-border-100 bg-custom-background-100 px-4 py-6">
<div className="flex items-start gap-4"> <div className="flex items-start gap-4">
<div className="h-10 w-10 flex-shrink-0"> <div className="h-10 w-10 flex-shrink-0">
<Image <Image

View File

@ -30,7 +30,7 @@ export interface ILeaveProjectModal {
} }
export const LeaveProjectModal: FC<ILeaveProjectModal> = observer((props) => { export const LeaveProjectModal: FC<ILeaveProjectModal> = observer((props) => {
const { project, isOpen } = props; const { project, isOpen, onClose } = props;
// router // router
const router = useRouter(); const router = useRouter();
const { workspaceSlug } = router.query; const { workspaceSlug } = router.query;
@ -48,6 +48,7 @@ export const LeaveProjectModal: FC<ILeaveProjectModal> = observer((props) => {
const handleClose = () => { const handleClose = () => {
reset({ ...defaultValues }); reset({ ...defaultValues });
onClose();
}; };
const onSubmit = async (data: any) => { const onSubmit = async (data: any) => {

View File

@ -21,10 +21,8 @@ export const ProjectMemberList: React.FC = observer(() => {
// router // router
const router = useRouter(); const router = useRouter();
const { workspaceSlug, projectId } = router.query; const { workspaceSlug, projectId } = router.query;
// store // store
const { const {
project: projectStore,
projectMember: { projectMembers, fetchProjectMembers }, projectMember: { projectMembers, fetchProjectMembers },
} = useMobxStore(); } = useMobxStore();

View File

@ -0,0 +1,91 @@
import { FC, useState, Fragment } from "react";
import { usePopper } from "react-popper";
import { Placement } from "@popperjs/core";
import { Combobox, Transition } from "@headlessui/react";
import { Check, ChevronDown, Search, User2 } from "lucide-react";
// types
import { IUserLite } from "types";
export type MembersAutocompleteProps = {
value: IUserLite;
onChange: (value: IUserLite) => void;
members: IUserLite[];
};
export const MembersAutocomplete: FC<MembersAutocompleteProps> = (props) => {
const { value, onChange, members } = props;
// states
const [query, setQuery] = useState("");
const filteredMembers =
query === ""
? members
: members.filter((person) =>
person.first_name.toLowerCase().replace(/\s+/g, "").includes(query.toLowerCase().replace(/\s+/g, ""))
);
return (
<Combobox value={value} onChange={onChange}>
<div className="relative mt-1">
<div className="relative w-full cursor-default overflow-hidden rounded-lg bg-white text-left shadow-md focus:outline-none focus-visible:ring-2 focus-visible:ring-white/75 focus-visible:ring-offset-2 focus-visible:ring-offset-teal-300 sm:text-sm">
<Combobox.Input
className="w-full border-none py-2 pl-3 pr-10 text-sm leading-5 text-gray-900 focus:ring-0"
placeholder="Select your Member"
displayValue={(person: string) => {
console.log(typeof person, person.length);
return person ? person.first_name : query;
}}
onChange={(event) => setQuery(event.target.value)}
/>
<Combobox.Button className="absolute inset-y-0 right-0 flex items-center pr-2">
<ChevronDown className="h-5 w-5 text-gray-400" aria-hidden="true" />
</Combobox.Button>
</div>
<Transition
as={Fragment}
leave="transition ease-in duration-100"
leaveFrom="opacity-100"
leaveTo="opacity-0"
afterLeave={() => setQuery("")}
>
<Combobox.Options className="absolute mt-1 max-h-60 w-full overflow-auto rounded-md bg-white py-1 text-base shadow-lg ring-1 ring-black/5 focus:outline-none sm:text-sm">
{filteredMembers.length === 0 && query !== "" ? (
<div className="relative cursor-default select-none py-2 px-4 text-gray-700">
Enter email to invite the user to your project.
</div>
) : (
filteredMembers.map((person) => (
<Combobox.Option
key={person.id}
className={({ active }) =>
`relative cursor-default select-none py-2 pl-10 pr-4 ${
active ? "bg-teal-600 text-white" : "text-gray-900"
}`
}
value={person}
>
{({ selected, active }) => (
<>
<span className={`block truncate ${selected ? "font-medium" : "font-normal"}`}>
{person.first_name}
</span>
{selected ? (
<span
className={`absolute inset-y-0 left-0 flex items-center pl-3 ${
active ? "text-white" : "text-teal-600"
}`}
>
<Check className="h-5 w-5" aria-hidden="true" />
</span>
) : null}
</>
)}
</Combobox.Option>
))
)}
</Combobox.Options>
</Transition>
</div>
</Combobox>
);
};

View File

@ -1,179 +0,0 @@
import React, { useState } from "react";
import { usePopper } from "react-popper";
import { Placement } from "@popperjs/core";
import { Combobox } from "@headlessui/react";
import { Check, ChevronDown, Search, User2 } from "lucide-react";
// ui
import { Avatar, AvatarGroup, Tooltip } from "@plane/ui";
// types
import { IUserLite } from "types";
type Props = {
members: IUserLite[] | undefined;
className?: string;
buttonClassName?: string;
optionsClassName?: string;
placement?: Placement;
hideDropdownArrow?: boolean;
disabled?: boolean;
} & (
| {
value: string[];
onChange: (data: string[]) => void;
multiple: true;
}
| {
value: string;
onChange: (data: string) => void;
multiple: false;
}
);
export const MembersSelect: React.FC<Props> = ({
value,
onChange,
members,
className = "",
buttonClassName = "",
optionsClassName = "",
placement,
hideDropdownArrow = false,
disabled = false,
multiple = true,
}) => {
const [query, setQuery] = useState("");
const [referenceElement, setReferenceElement] = useState<HTMLButtonElement | null>(null);
const [popperElement, setPopperElement] = useState<HTMLDivElement | null>(null);
const { styles, attributes } = usePopper(referenceElement, popperElement, {
placement: placement ?? "bottom-start",
modifiers: [
{
name: "preventOverflow",
options: {
padding: 12,
},
},
],
});
const options = members?.map((member) => ({
value: member.id,
query: member.display_name,
content: (
<div className="flex items-center gap-2">
<Avatar name={member.display_name} src={member.avatar} />
{member.display_name}
</div>
),
}));
const filteredOptions =
query === "" ? options : options?.filter((option) => option.query.toLowerCase().includes(query.toLowerCase()));
const label = (
<Tooltip
tooltipHeading="Assignee"
tooltipContent={
value && value.length > 0
? members
?.filter((m) => value.includes(m.display_name))
.map((m) => m.display_name)
.join(", ")
: "No Assignee"
}
position="top"
>
<div className="flex items-center cursor-pointer h-full w-full gap-2 text-custom-text-200">
{value && value.length > 0 && Array.isArray(value) ? (
<AvatarGroup showTooltip={false}>
{value.map((assigneeId) => {
const member = members?.find((m) => m.id === assigneeId);
if (!member) return null;
return <Avatar key={member.id} name={member.display_name} src={member.avatar} />;
})}
</AvatarGroup>
) : (
<span
className="flex items-center justify-between gap-1 h-full w-full text-xs px-2.5 py-1 rounded border-[0.5px] border-custom-border-300 duration-300 focus:outline-none
"
>
<User2 className="h-3 w-3" />
</span>
)}
</div>
</Tooltip>
);
const comboboxProps: any = { value, onChange, disabled };
if (multiple) comboboxProps.multiple = true;
return (
<Combobox as="div" className={`flex-shrink-0 text-left ${className}`} {...comboboxProps}>
<Combobox.Button as={React.Fragment}>
<button
ref={setReferenceElement}
type="button"
className={`flex items-center justify-between gap-1 w-full text-xs ${
disabled ? "cursor-not-allowed text-custom-text-200" : "cursor-pointer hover:bg-custom-background-80"
} ${buttonClassName}`}
>
{label}
{!hideDropdownArrow && !disabled && <ChevronDown className="h-3 w-3" aria-hidden="true" />}
</button>
</Combobox.Button>
<Combobox.Options className="fixed z-10">
<div
className={`border border-custom-border-300 px-2 py-2.5 rounded bg-custom-background-100 text-xs shadow-custom-shadow-rg focus:outline-none w-48 whitespace-nowrap my-1 ${optionsClassName}`}
ref={setPopperElement}
style={styles.popper}
{...attributes.popper}
>
<div className="flex w-full items-center justify-start rounded border border-custom-border-200 bg-custom-background-90 px-2">
<Search className="h-3.5 w-3.5 text-custom-text-300" />
<Combobox.Input
className="w-full bg-transparent py-1 px-2 text-xs text-custom-text-200 placeholder:text-custom-text-400 focus:outline-none"
value={query}
onChange={(e) => setQuery(e.target.value)}
placeholder="Search"
displayValue={(assigned: any) => assigned?.name}
/>
</div>
<div className={`mt-2 space-y-1 max-h-48 overflow-y-scroll`}>
{filteredOptions ? (
filteredOptions.length > 0 ? (
filteredOptions.map((option) => (
<Combobox.Option
key={option.value}
value={option.value}
className={({ active, selected }) =>
`flex items-center justify-between gap-2 cursor-pointer select-none truncate rounded px-1 py-1.5 ${
active ? "bg-custom-background-80" : ""
} ${selected ? "text-custom-text-100" : "text-custom-text-200"}`
}
>
{({ selected }) => (
<>
{option.content}
{selected && <Check className={`h-3.5 w-3.5`} />}
</>
)}
</Combobox.Option>
))
) : (
<span className="flex items-center gap-2 p-1">
<p className="text-left text-custom-text-200 ">No matching results</p>
</span>
)
) : (
<p className="text-center text-custom-text-200">Loading...</p>
)}
</div>
</div>
</Combobox.Options>
</Combobox>
);
};

View File

@ -20,6 +20,7 @@ import { IUser, TUserProjectRole } from "types";
import { WORKSPACE_MEMBERS } from "constants/fetch-keys"; import { WORKSPACE_MEMBERS } from "constants/fetch-keys";
// constants // constants
import { ROLE } from "constants/workspace"; import { ROLE } from "constants/workspace";
import { MembersAutocomplete } from "./members-autocomplete";
type Props = { type Props = {
isOpen: boolean; isOpen: boolean;
@ -53,38 +54,31 @@ const workspaceService = new WorkspaceService();
export const SendProjectInvitationModal: React.FC<Props> = observer((props) => { export const SendProjectInvitationModal: React.FC<Props> = observer((props) => {
const { isOpen, setIsOpen, members, user, onSuccess } = props; const { isOpen, setIsOpen, members, user, onSuccess } = props;
// router
const router = useRouter(); const router = useRouter();
const { workspaceSlug, projectId } = router.query; const { workspaceSlug, projectId } = router.query;
// hooks
const { setToastAlert } = useToast(); const { setToastAlert } = useToast();
// store
const {
user: { currentProjectRole },
projectMember: { projectMembers },
} = useMobxStore();
const { user: userStore } = useMobxStore(); const projectUsers = projectMembers?.map((m) => m.member) || [];
const userRole = userStore.currentProjectRole;
const { data: people } = useSWR(
workspaceSlug ? WORKSPACE_MEMBERS(workspaceSlug as string) : null,
workspaceSlug ? () => workspaceService.fetchWorkspaceMembers(workspaceSlug as string) : null
);
const { const {
formState: { errors, isSubmitting }, formState: { errors, isSubmitting },
reset, reset,
handleSubmit, handleSubmit,
control, control,
} = useForm<FormValues>(); } = useForm<FormValues>({ defaultValues });
const { fields, append, remove } = useFieldArray({ const { fields, append, remove } = useFieldArray({
control, control,
name: "members", name: "members",
}); });
const uninvitedPeople = people?.filter((person) => {
const isInvited = members?.find((member) => member.memberId === person.member.id);
return !isInvited;
});
const onSubmit = async (formData: FormValues) => { const onSubmit = async (formData: FormValues) => {
if (!workspaceSlug || !projectId || isSubmitting) return; if (!workspaceSlug || !projectId || isSubmitting) return;
@ -124,28 +118,6 @@ export const SendProjectInvitationModal: React.FC<Props> = observer((props) => {
}); });
}; };
useEffect(() => {
if (fields.length === 0) {
append([
{
role: 5,
member_id: "",
},
]);
}
}, [fields, append]);
const options = uninvitedPeople?.map((person) => ({
value: person.member.id,
query: person.member.display_name,
content: (
<div className="flex items-center gap-2">
<Avatar name={person.member?.display_name} src={person.member?.avatar} />
{person.member.display_name} ({person.member.first_name + " " + person.member.last_name})
</div>
),
}));
return ( return (
<Transition.Root show={isOpen} as={React.Fragment}> <Transition.Root show={isOpen} as={React.Fragment}>
<Dialog as="div" className="relative z-20" onClose={handleClose}> <Dialog as="div" className="relative z-20" onClose={handleClose}>
@ -187,6 +159,14 @@ export const SendProjectInvitationModal: React.FC<Props> = observer((props) => {
<div key={field.id} className="group grid grid-cols-12 gap-x-4 mb-1 text-sm items-start"> <div key={field.id} className="group grid grid-cols-12 gap-x-4 mb-1 text-sm items-start">
<div className="flex flex-col gap-1 col-span-7"> <div className="flex flex-col gap-1 col-span-7">
<Controller <Controller
control={control}
name={`members.${index}.member_id`}
rules={{ required: "Please select a member" }}
render={({ field: { value, onChange } }) => (
<MembersAutocomplete value={value} onChange={onChange} members={projectUsers} />
)}
/>
{/* <Controller
control={control} control={control}
name={`members.${index}.member_id`} name={`members.${index}.member_id`}
rules={{ required: "Please select a member" }} rules={{ required: "Please select a member" }}
@ -217,7 +197,7 @@ export const SendProjectInvitationModal: React.FC<Props> = observer((props) => {
/> />
); );
}} }}
/> /> */}
{errors.members && errors.members[index]?.member_id && ( {errors.members && errors.members[index]?.member_id && (
<span className="text-sm px-1 text-red-500"> <span className="text-sm px-1 text-red-500">
{errors.members[index]?.member_id?.message} {errors.members[index]?.member_id?.message}
@ -246,7 +226,7 @@ export const SendProjectInvitationModal: React.FC<Props> = observer((props) => {
width="w-full" width="w-full"
> >
{Object.entries(ROLE).map(([key, label]) => { {Object.entries(ROLE).map(([key, label]) => {
if (parseInt(key) > (userRole ?? 5)) return null; if (parseInt(key) > (currentProjectRole ?? 5)) return null;
return ( return (
<CustomSelect.Option key={key} value={key}> <CustomSelect.Option key={key} value={key}>

View File

@ -149,29 +149,38 @@ export const ProjectSidebarList: FC = observer(() => {
</button> </button>
</div> </div>
)} )}
<Disclosure.Panel as="div" className="space-y-2"> <Transition
{orderedFavProjects.map((project, index) => ( enter="transition duration-100 ease-out"
<Draggable enterFrom="transform scale-95 opacity-0"
key={project.id} enterTo="transform scale-100 opacity-100"
draggableId={project.id} leave="transition duration-75 ease-out"
index={index} leaveFrom="transform scale-100 opacity-100"
isDragDisabled={!project.is_member} leaveTo="transform scale-95 opacity-0"
> >
{(provided, snapshot) => ( <Disclosure.Panel as="div" className="space-y-2">
<div ref={provided.innerRef} {...provided.draggableProps}> {orderedFavProjects.map((project, index) => (
<ProjectSidebarListItem <Draggable
key={project.id} key={project.id}
project={project} draggableId={project.id}
provided={provided} index={index}
snapshot={snapshot} isDragDisabled={!project.is_member}
handleCopyText={() => handleCopyText(project.id)} >
shortContextMenu {(provided, snapshot) => (
/> <div ref={provided.innerRef} {...provided.draggableProps}>
</div> <ProjectSidebarListItem
)} key={project.id}
</Draggable> project={project}
))} provided={provided}
</Disclosure.Panel> snapshot={snapshot}
handleCopyText={() => handleCopyText(project.id)}
shortContextMenu
/>
</div>
)}
</Draggable>
))}
</Disclosure.Panel>
</Transition>
{provided.placeholder} {provided.placeholder}
</> </>
)} )}

View File

@ -104,7 +104,7 @@ export const DeleteProjectViewModal: React.FC<Props> = observer((props) => {
<div className="mt-2"> <div className="mt-2">
<p className="text-sm text-custom-text-200"> <p className="text-sm text-custom-text-200">
Are you sure you want to delete view-{" "} Are you sure you want to delete view-{" "}
<span className="break-words font-medium text-custom-text-100">{data?.name}</span>? All of the <span className="break-all font-medium text-custom-text-100">{data?.name}</span>? All of the
data related to the view will be permanently removed. This action cannot be undone. data related to the view will be permanently removed. This action cannot be undone.
</p> </p>
</div> </div>

View File

@ -61,12 +61,12 @@ export const ProjectViewListItem: React.FC<Props> = observer((props) => {
<a className="flex items-center justify-between relative rounded p-4 w-full"> <a className="flex items-center justify-between relative rounded p-4 w-full">
<div className="flex items-center justify-between w-full"> <div className="flex items-center justify-between w-full">
<div className="flex items-center gap-4"> <div className="flex items-center gap-4">
<div className="grid place-items-center h-10 w-10 rounded bg-custom-background-90 group-hover:bg-custom-background-100"> <div className="grid place-items-center flex-shrink-0 h-10 w-10 rounded bg-custom-background-90 group-hover:bg-custom-background-100">
<PhotoFilterIcon className="h-3.5 w-3.5" /> <PhotoFilterIcon className="h-3.5 w-3.5" />
</div> </div>
<div className="flex flex-col"> <div className="flex flex-col">
<p className="truncate text-sm leading-4 font-medium">{truncateText(view.name, 75)}</p> <p className="text-sm leading-4 font-medium break-all">{truncateText(view.name, 75)}</p>
{view?.description && <p className="text-xs text-custom-text-200">{view.description}</p>} {view?.description && <p className="text-xs text-custom-text-200 break-all">{view.description}</p>}
</div> </div>
</div> </div>
<div className="ml-2 flex flex-shrink-0"> <div className="ml-2 flex flex-shrink-0">

View File

@ -161,7 +161,7 @@ export const CreateWorkspaceForm: FC<Props> = observer((props) => {
}} }}
ref={ref} ref={ref}
hasError={Boolean(errors.slug)} hasError={Boolean(errors.slug)}
placeholder="Enter workspace name..." placeholder="Enter workspace url..."
className="block rounded-md bg-transparent py-2 !px-0 text-sm w-full border-none" className="block rounded-md bg-transparent py-2 !px-0 text-sm w-full border-none"
/> />
)} )}

View File

@ -141,6 +141,7 @@ export const DeleteWorkspaceModal: React.FC<Props> = observer((props) => {
hasError={Boolean(errors.workspaceName)} hasError={Boolean(errors.workspaceName)}
placeholder="Workspace name" placeholder="Workspace name"
className="mt-2 w-full" className="mt-2 w-full"
autoComplete="off"
/> />
)} )}
/> />
@ -165,6 +166,7 @@ export const DeleteWorkspaceModal: React.FC<Props> = observer((props) => {
hasError={Boolean(errors.confirmDelete)} hasError={Boolean(errors.confirmDelete)}
placeholder="Enter 'delete my workspace'" placeholder="Enter 'delete my workspace'"
className="mt-2 w-full" className="mt-2 w-full"
autoComplete="off"
/> />
)} )}
/> />

View File

@ -1,6 +1,7 @@
import { useState, FC } from "react"; import { useState, FC } from "react";
import Link from "next/link"; import Link from "next/link";
import { useRouter } from "next/router"; import { useRouter } from "next/router";
import { mutate } from "swr";
// mobx store // mobx store
import { useMobxStore } from "lib/mobx/store-provider"; import { useMobxStore } from "lib/mobx/store-provider";
// hooks // hooks
@ -39,7 +40,7 @@ export const WorkspaceMembersListItem: FC<Props> = (props) => {
// store // store
const { const {
workspaceMember: { removeMember, updateMember, deleteWorkspaceInvitation }, workspaceMember: { removeMember, updateMember, deleteWorkspaceInvitation },
user: { currentWorkspaceMemberInfo, currentWorkspaceRole }, user: { currentWorkspaceMemberInfo, currentWorkspaceRole, currentUser, currentUserSettings },
} = useMobxStore(); } = useMobxStore();
const isAdmin = currentWorkspaceRole === 20; const isAdmin = currentWorkspaceRole === 20;
// states // states
@ -51,14 +52,22 @@ export const WorkspaceMembersListItem: FC<Props> = (props) => {
if (!workspaceSlug) return; if (!workspaceSlug) return;
if (member.member) if (member.member)
await removeMember(workspaceSlug.toString(), member.id).catch((err) => { await removeMember(workspaceSlug.toString(), member.id)
const error = err?.error; .then(() => {
setToastAlert({ const memberId = member.memberId;
type: "error",
title: "Error", if (memberId === currentUser?.id && currentUserSettings) {
message: error || "Something went wrong", if (currentUserSettings.workspace?.invites > 0) router.push("/invitations");
else router.push("/create-workspace");
}
})
.catch((err) => {
setToastAlert({
type: "error",
title: "Error",
message: err?.error || "Something went wrong",
});
}); });
});
else else
await deleteWorkspaceInvitation(workspaceSlug.toString(), member.id) await deleteWorkspaceInvitation(workspaceSlug.toString(), member.id)
.then(() => { .then(() => {
@ -69,12 +78,17 @@ export const WorkspaceMembersListItem: FC<Props> = (props) => {
}); });
}) })
.catch((err) => { .catch((err) => {
const error = err?.error;
setToastAlert({ setToastAlert({
type: "error", type: "error",
title: "Error", title: "Error",
message: error || "Something went wrong", message: err?.error || "Something went wrong",
});
})
.finally(() => {
mutate(`WORKSPACE_INVITATIONS_${workspaceSlug.toString()}`, (prevData: any) => {
if (!prevData) return prevData;
return prevData.filter((item: any) => item.id !== member.id);
}); });
}); });
}; };

View File

@ -29,9 +29,15 @@ export const WorkspaceMembersList: FC<{ searchQuery: string }> = observer(({ sea
); );
const searchedMembers = workspaceMembersWithInvitations?.filter((member: any) => { const searchedMembers = workspaceMembersWithInvitations?.filter((member: any) => {
const fullName = `${member.first_name} ${member.last_name}`.toLowerCase(); const email = member.email?.toLowerCase();
const displayName = member.display_name.toLowerCase(); const displayName = member.display_name.toLowerCase();
return displayName.includes(searchQuery.toLowerCase()) || fullName.includes(searchQuery.toLowerCase()); const fullName = `${member.first_name} ${member.last_name}`.toLowerCase();
return (
displayName.includes(searchQuery.toLowerCase()) ||
fullName.includes(searchQuery.toLowerCase()) ||
email?.includes(searchQuery.toLowerCase())
);
}); });
if ( if (

View File

@ -64,7 +64,7 @@ export const WorkspaceSettingsSidebar = () => {
<a> <a>
<div <div
className={`px-4 py-2 text-sm font-medium rounded-md ${ className={`px-4 py-2 text-sm font-medium rounded-md ${
(link.label === "Import" ? router.asPath.includes(link.href) : router.asPath === link.href) router.pathname.split("/")?.[3] === link.href.split("/")?.[3]
? "bg-custom-primary-100/10 text-custom-primary-100" ? "bg-custom-primary-100/10 text-custom-primary-100"
: "text-custom-sidebar-text-200 hover:bg-custom-sidebar-background-80 focus:bg-custom-sidebar-background-80" : "text-custom-sidebar-text-200 hover:bg-custom-sidebar-background-80 focus:bg-custom-sidebar-background-80"
}`} }`}

View File

@ -43,7 +43,7 @@ const ProjectIntegrationsPage: NextPageWithLayout = () => {
return ( return (
<div className={`pr-9 py-8 gap-10 w-full overflow-y-auto ${isAdmin ? "" : "opacity-60"}`}> <div className={`pr-9 py-8 gap-10 w-full overflow-y-auto ${isAdmin ? "" : "opacity-60"}`}>
<div className="flex items-center py-3.5 border-b border-custom-border-200"> <div className="flex items-center py-3.5 border-b border-custom-border-100">
<h3 className="text-xl font-medium">Integrations</h3> <h3 className="text-xl font-medium">Integrations</h3>
</div> </div>
{workspaceIntegrations ? ( {workspaceIntegrations ? (

View File

@ -9,12 +9,12 @@ import ExportGuide from "components/exporter/guide";
import { NextPageWithLayout } from "types/app"; import { NextPageWithLayout } from "types/app";
const ExportsPage: NextPageWithLayout = () => ( const ExportsPage: NextPageWithLayout = () => (
<div className="pr-9 py-8 w-full overflow-y-auto"> <div className="pr-9 py-8 w-full overflow-y-auto">
<div className="flex items-center py-3.5 border-b border-custom-border-100"> <div className="flex items-center py-3.5 border-b border-custom-border-100">
<h3 className="text-xl font-medium">Exports</h3> <h3 className="text-xl font-medium">Exports</h3>
</div> </div>
<ExportGuide /> <ExportGuide />
</div> </div>
); );
ExportsPage.getLayout = function getLayout(page: ReactElement) { ExportsPage.getLayout = function getLayout(page: ReactElement) {

View File

@ -208,29 +208,38 @@ export class WorkspaceMemberStore implements IWorkspaceMemberStore {
* @param data * @param data
*/ */
updateMember = async (workspaceSlug: string, memberId: string, data: Partial<IWorkspaceMember>) => { updateMember = async (workspaceSlug: string, memberId: string, data: Partial<IWorkspaceMember>) => {
const members = this.members?.[workspaceSlug]; const originalMembers = [...this.members?.[workspaceSlug]]; // in case of error, we will revert back to original members
members?.map((m) => (m.id === memberId ? { ...m, ...data } : m));
const members = [...this.members?.[workspaceSlug]];
const index = members.findIndex((m) => m.id === memberId);
members[index] = { ...members[index], ...data };
// optimistic update
runInAction(() => {
this.loader = true;
this.error = null;
this.members = {
...this.members,
[workspaceSlug]: members,
};
});
try { try {
runInAction(() => {
this.loader = true;
this.error = null;
});
await this.workspaceService.updateWorkspaceMember(workspaceSlug, memberId, data); await this.workspaceService.updateWorkspaceMember(workspaceSlug, memberId, data);
runInAction(() => { runInAction(() => {
this.loader = false; this.loader = false;
this.error = null; this.error = null;
this.members = {
...this.members,
[workspaceSlug]: members,
};
}); });
} catch (error) { } catch (error) {
runInAction(() => { runInAction(() => {
this.loader = false; this.loader = false;
this.error = error; this.error = error;
this.members = {
...this.members,
[workspaceSlug]: originalMembers,
};
}); });
throw error; throw error;
@ -243,8 +252,20 @@ export class WorkspaceMemberStore implements IWorkspaceMemberStore {
* @param memberId * @param memberId
*/ */
removeMember = async (workspaceSlug: string, memberId: string) => { removeMember = async (workspaceSlug: string, memberId: string) => {
const members = this.members?.[workspaceSlug]; const members = [...this.members?.[workspaceSlug]];
members?.filter((m) => m.id !== memberId); const originalMembers = this.members?.[workspaceSlug]; // in case of error, we will revert back to original members
// removing member from the array
const index = members.findIndex((m) => m.id === memberId);
members.splice(index, 1);
// optimistic update
runInAction(() => {
this.members = {
...this.members,
[workspaceSlug]: members,
};
});
try { try {
runInAction(() => { runInAction(() => {
@ -257,15 +278,15 @@ export class WorkspaceMemberStore implements IWorkspaceMemberStore {
runInAction(() => { runInAction(() => {
this.loader = false; this.loader = false;
this.error = null; this.error = null;
this.members = {
...this.members,
[workspaceSlug]: members,
};
}); });
} catch (error) { } catch (error) {
runInAction(() => { runInAction(() => {
this.loader = false; this.loader = false;
this.error = error; this.error = error;
this.members = {
...this.members,
[workspaceSlug]: originalMembers,
};
}); });
throw error; throw error;