feat: made emoji-icon-picker

fix: google prompt coming up after leaving sign in, refractor: saving views data to db instead of local-storage
This commit is contained in:
Dakshesh Jain 2022-12-19 20:13:43 +05:30
parent f52724fd86
commit 13985df860
20 changed files with 1388 additions and 46 deletions

View File

@ -2,7 +2,7 @@ import React, { useState, useEffect } from "react";
// swr // swr
import useSWR, { mutate } from "swr"; import useSWR, { mutate } from "swr";
// react hook form // react hook form
import { useForm } from "react-hook-form"; import { useForm, Controller } from "react-hook-form";
// headless // headless
import { Dialog, Transition } from "@headlessui/react"; import { Dialog, Transition } from "@headlessui/react";
// services // services
@ -18,7 +18,7 @@ import { PROJECTS_LIST, WORKSPACE_MEMBERS } from "constants/fetch-keys";
import useUser from "lib/hooks/useUser"; import useUser from "lib/hooks/useUser";
import useToast from "lib/hooks/useToast"; import useToast from "lib/hooks/useToast";
// ui // ui
import { Button, Input, TextArea, Select } from "ui"; import { Button, Input, TextArea, Select, EmojiIconPicker } from "ui";
// types // types
import { IProject } from "types"; import { IProject } from "types";
@ -32,6 +32,7 @@ const defaultValues: Partial<IProject> = {
identifier: "", identifier: "",
description: "", description: "",
network: 0, network: 0,
icon: "",
}; };
const IsGuestCondition: React.FC<{ const IsGuestCondition: React.FC<{
@ -83,6 +84,7 @@ const CreateProjectModal: React.FC<Props> = ({ isOpen, setIsOpen }) => {
reset, reset,
setError, setError,
clearErrors, clearErrors,
control,
watch, watch,
setValue, setValue,
} = useForm<IProject>({ } = useForm<IProject>({
@ -201,6 +203,22 @@ const CreateProjectModal: React.FC<Props> = ({ isOpen, setIsOpen }) => {
</p> </p>
</div> </div>
<div className="space-y-3"> <div className="space-y-3">
<div>
<label htmlFor="icon" className="text-gray-500 mb-2">
Icon
</label>
<Controller
control={control}
name="icon"
render={({ field: { value, onChange } }) => (
<EmojiIconPicker
label={value ? String.fromCodePoint(parseInt(value)) : "Select Icon"}
value={value}
onChange={onChange}
/>
)}
/>
</div>
<div> <div>
<Input <Input
id="name" id="name"

View File

@ -1,13 +1,14 @@
// react // react
import { useCallback } from "react"; import { useCallback } from "react";
// react-hook-form // react-hook-form
import { UseFormRegister, UseFormSetError } from "react-hook-form"; import { Controller } from "react-hook-form";
import type { Control, UseFormRegister, UseFormSetError } from "react-hook-form";
// services // services
import projectServices from "lib/services/project.service"; import projectServices from "lib/services/project.service";
// hooks // hooks
import useUser from "lib/hooks/useUser"; import useUser from "lib/hooks/useUser";
// ui // ui
import { Button, Input, Select, TextArea } from "ui"; import { Button, Input, Select, TextArea, EmojiIconPicker } from "ui";
// types // types
import { IProject } from "types"; import { IProject } from "types";
// constants // constants
@ -18,11 +19,18 @@ type Props = {
errors: any; errors: any;
setError: UseFormSetError<IProject>; setError: UseFormSetError<IProject>;
isSubmitting: boolean; isSubmitting: boolean;
control: Control<IProject, any>;
}; };
const NETWORK_CHOICES = { "0": "Secret", "2": "Public" }; const NETWORK_CHOICES = { "0": "Secret", "2": "Public" };
const GeneralSettings: React.FC<Props> = ({ register, errors, setError, isSubmitting }) => { const GeneralSettings: React.FC<Props> = ({
register,
errors,
setError,
isSubmitting,
control,
}) => {
const { activeWorkspace } = useUser(); const { activeWorkspace } = useUser();
const checkIdentifier = (slug: string, value: string) => { const checkIdentifier = (slug: string, value: string) => {
@ -44,8 +52,26 @@ const GeneralSettings: React.FC<Props> = ({ register, errors, setError, isSubmit
This information will be displayed to every member of the project. This information will be displayed to every member of the project.
</p> </p>
</div> </div>
<div className="grid grid-cols-4 gap-3"> <div className="grid grid-cols-10 gap-3 items-center">
<div className="col-span-2"> <div className="col-span-1">
<div>
<label htmlFor="icon" className="text-gray-500 mb-2">
Icon
</label>
<Controller
control={control}
name="icon"
render={({ field: { value, onChange } }) => (
<EmojiIconPicker
label={value ? String.fromCodePoint(parseInt(value)) : "Select Icon"}
value={value}
onChange={onChange}
/>
)}
/>
</div>
</div>
<div className="col-span-5">
<Input <Input
id="name" id="name"
name="name" name="name"
@ -58,7 +84,7 @@ const GeneralSettings: React.FC<Props> = ({ register, errors, setError, isSubmit
}} }}
/> />
</div> </div>
<div> <div className="col-span-2">
<Select <Select
name="network" name="network"
id="network" id="network"
@ -73,7 +99,7 @@ const GeneralSettings: React.FC<Props> = ({ register, errors, setError, isSubmit
}} }}
/> />
</div> </div>
<div> <div className="col-span-2">
<Input <Input
id="identifier" id="identifier"
name="identifier" name="identifier"

View File

@ -62,9 +62,16 @@ const ProjectsList: React.FC<Props> = ({ navigation, sidebarCollapse }) => {
sidebarCollapse ? "justify-center" : "" sidebarCollapse ? "justify-center" : ""
}`} }`}
> >
{project.icon ? (
<span className="text-white rounded h-7 w-7 grid place-items-center uppercase flex-shrink-0">
{String.fromCodePoint(parseInt(project.icon))}
</span>
) : (
<span className="bg-gray-700 text-white rounded h-7 w-7 grid place-items-center uppercase flex-shrink-0"> <span className="bg-gray-700 text-white rounded h-7 w-7 grid place-items-center uppercase flex-shrink-0">
{project?.name.charAt(0)} {project?.name.charAt(0)}
</span> </span>
)}
{!sidebarCollapse && ( {!sidebarCollapse && (
<span className="flex items-center justify-between w-full"> <span className="flex items-center justify-between w-full">
{project?.name} {project?.name}

View File

@ -1,4 +1,4 @@
import { FC, CSSProperties, useEffect, useRef, useCallback } from "react"; import { FC, CSSProperties, useEffect, useRef, useCallback, useState } from "react";
// next // next
import Script from "next/script"; import Script from "next/script";
@ -11,9 +11,10 @@ export interface IGoogleLoginButton {
export const GoogleLoginButton: FC<IGoogleLoginButton> = (props) => { export const GoogleLoginButton: FC<IGoogleLoginButton> = (props) => {
const googleSignInButton = useRef<HTMLDivElement>(null); const googleSignInButton = useRef<HTMLDivElement>(null);
const [gsiScriptLoaded, setGsiScriptLoaded] = useState(false);
const loadScript = useCallback(() => { const loadScript = useCallback(() => {
if (!googleSignInButton.current) return; if (!googleSignInButton.current || gsiScriptLoaded) return;
window?.google?.accounts.id.initialize({ window?.google?.accounts.id.initialize({
client_id: process.env.NEXT_PUBLIC_GOOGLE_CLIENTID || "", client_id: process.env.NEXT_PUBLIC_GOOGLE_CLIENTID || "",
callback: props.onSuccess as any, callback: props.onSuccess as any,
@ -30,12 +31,16 @@ export const GoogleLoginButton: FC<IGoogleLoginButton> = (props) => {
} as GsiButtonConfiguration // customization attributes } as GsiButtonConfiguration // customization attributes
); );
window?.google?.accounts.id.prompt(); // also display the One Tap dialog window?.google?.accounts.id.prompt(); // also display the One Tap dialog
}, [props.onSuccess]); setGsiScriptLoaded(true);
}, [props.onSuccess, gsiScriptLoaded]);
useEffect(() => { useEffect(() => {
if (window?.google?.accounts?.id) { if (window?.google?.accounts?.id) {
loadScript(); loadScript();
} }
return () => {
window?.google?.accounts.id.cancel();
};
}, [loadScript]); }, [loadScript]);
return ( return (

View File

@ -64,6 +64,8 @@ export const PROJECT_MEMBERS = (workspaceSlug: string, projectId: string) =>
`/api/workspaces/${workspaceSlug}/projects/${projectId}/members/`; `/api/workspaces/${workspaceSlug}/projects/${projectId}/members/`;
export const PROJECT_MEMBER_DETAIL = (workspaceSlug: string, projectId: string, memberId: string) => export const PROJECT_MEMBER_DETAIL = (workspaceSlug: string, projectId: string, memberId: string) =>
`/api/workspaces/${workspaceSlug}/projects/${projectId}/members/${memberId}/`; `/api/workspaces/${workspaceSlug}/projects/${projectId}/members/${memberId}/`;
export const PROJECT_MEMBER_ME = (workspaceSlug: string, projectId: string) =>
`/api/workspaces/${workspaceSlug}/projects/${projectId}/project-members/me/`;
export const PROJECT_VIEW_ENDPOINT = (workspaceSlug: string, projectId: string) => export const PROJECT_VIEW_ENDPOINT = (workspaceSlug: string, projectId: string) =>
`/api/workspaces/${workspaceSlug}/projects/${projectId}/project-views/`; `/api/workspaces/${workspaceSlug}/projects/${projectId}/project-views/`;

View File

@ -68,13 +68,9 @@ export const reducer: ReducerFunctionType = (state, action) => {
...state, ...state,
collapsed: !state.collapsed, collapsed: !state.collapsed,
}; };
localStorage.setItem("theme", JSON.stringify(newState));
return newState; return newState;
case REHYDRATE_THEME: { case REHYDRATE_THEME: {
let newState: any = localStorage.getItem("theme"); const newState = payload;
if (newState !== null) {
newState = JSON.parse(newState);
}
return { ...initialState, ...newState }; return { ...initialState, ...newState };
} }
case SET_ISSUE_VIEW: { case SET_ISSUE_VIEW: {
@ -82,7 +78,6 @@ export const reducer: ReducerFunctionType = (state, action) => {
...state, ...state,
issueView: payload?.issueView || "list", issueView: payload?.issueView || "list",
}; };
localStorage.setItem("theme", JSON.stringify(newState));
return { return {
...state, ...state,
...newState, ...newState,
@ -93,7 +88,6 @@ export const reducer: ReducerFunctionType = (state, action) => {
...state, ...state,
groupByProperty: payload?.groupByProperty || null, groupByProperty: payload?.groupByProperty || null,
}; };
localStorage.setItem("theme", JSON.stringify(newState));
return { return {
...state, ...state,
...newState, ...newState,
@ -104,7 +98,6 @@ export const reducer: ReducerFunctionType = (state, action) => {
...state, ...state,
orderBy: payload?.orderBy || null, orderBy: payload?.orderBy || null,
}; };
localStorage.setItem("theme", JSON.stringify(newState));
return { return {
...state, ...state,
...newState, ...newState,
@ -115,7 +108,6 @@ export const reducer: ReducerFunctionType = (state, action) => {
...state, ...state,
filterIssue: payload?.filterIssue || null, filterIssue: payload?.filterIssue || null,
}; };
localStorage.setItem("theme", JSON.stringify(newState));
return { return {
...state, ...state,
...newState, ...newState,
@ -127,6 +119,10 @@ export const reducer: ReducerFunctionType = (state, action) => {
} }
}; };
const saveDataToServer = async (workspaceSlug: string, projectID: string, state: any) => {
await projectService.setProjectView(workspaceSlug, projectID, state);
};
export const ThemeContextProvider: React.FC<{ children: React.ReactNode }> = ({ children }) => { export const ThemeContextProvider: React.FC<{ children: React.ReactNode }> = ({ children }) => {
const [state, dispatch] = useReducer(reducer, initialState); const [state, dispatch] = useReducer(reducer, initialState);
@ -145,16 +141,6 @@ export const ThemeContextProvider: React.FC<{ children: React.ReactNode }> = ({
}); });
}, []); }, []);
const saveDataToServer = useCallback(() => {
if (!activeProject || !activeWorkspace) return;
projectService
.setProjectView(activeWorkspace.slug, activeProject.id, state)
.then((res) => {
console.log("saved", res);
})
.catch((error) => {});
}, [activeProject, activeWorkspace, state]);
const setIssueView = useCallback( const setIssueView = useCallback(
(display: "list" | "kanban") => { (display: "list" | "kanban") => {
dispatch({ dispatch({
@ -163,9 +149,14 @@ export const ThemeContextProvider: React.FC<{ children: React.ReactNode }> = ({
issueView: display, issueView: display,
}, },
}); });
saveDataToServer();
if (!activeWorkspace || !activeProject) return;
saveDataToServer(activeWorkspace.slug, activeProject.id, {
...state,
issueView: display,
});
}, },
[saveDataToServer] [activeProject, activeWorkspace, state]
); );
const setGroupByProperty = useCallback( const setGroupByProperty = useCallback(
@ -176,9 +167,14 @@ export const ThemeContextProvider: React.FC<{ children: React.ReactNode }> = ({
groupByProperty: property, groupByProperty: property,
}, },
}); });
saveDataToServer();
if (!activeWorkspace || !activeProject) return;
saveDataToServer(activeWorkspace.slug, activeProject.id, {
...state,
groupByProperty: property,
});
}, },
[saveDataToServer] [activeProject, activeWorkspace, state]
); );
const setOrderBy = useCallback( const setOrderBy = useCallback(
@ -189,11 +185,12 @@ export const ThemeContextProvider: React.FC<{ children: React.ReactNode }> = ({
orderBy: property, orderBy: property,
}, },
}); });
saveDataToServer();
},
[saveDataToServer]
);
if (!activeWorkspace || !activeProject) return;
saveDataToServer(activeWorkspace.slug, activeProject.id, state);
},
[activeProject, activeWorkspace, state]
);
const setFilterIssue = useCallback( const setFilterIssue = useCallback(
(property: "activeIssue" | "backlogIssue" | null) => { (property: "activeIssue" | "backlogIssue" | null) => {
dispatch({ dispatch({
@ -202,9 +199,14 @@ export const ThemeContextProvider: React.FC<{ children: React.ReactNode }> = ({
filterIssue: property, filterIssue: property,
}, },
}); });
saveDataToServer();
if (!activeWorkspace || !activeProject) return;
saveDataToServer(activeWorkspace.slug, activeProject.id, {
...state,
filterIssue: property,
});
}, },
[saveDataToServer] [activeProject, activeWorkspace, state]
); );
useEffect(() => { useEffect(() => {

View File

@ -1,4 +1,3 @@
// hooks
import useTheme from "./useTheme"; import useTheme from "./useTheme";
import useUser from "./useUser"; import useUser from "./useUser";
// commons // commons

View File

@ -18,6 +18,7 @@ const initialValues: Properties = {
start_date: false, start_date: false,
target_date: false, target_date: false,
cycle: false, cycle: false,
children_count: false,
}; };
const useIssuesProperties = (workspaceSlug?: string, projectId?: string) => { const useIssuesProperties = (workspaceSlug?: string, projectId?: string) => {

View File

@ -18,6 +18,7 @@ const initialValues: Properties = {
start_date: false, start_date: false,
target_date: false, target_date: false,
cycle: false, cycle: false,
children_count: false,
}; };
const useMyIssuesProperties = (issues?: IIssue[]) => { const useMyIssuesProperties = (issues?: IIssue[]) => {

View File

@ -0,0 +1,19 @@
import React, { useEffect } from "react";
const useOutsideClickDetector = (ref: React.RefObject<HTMLElement>, callback: () => void) => {
const handleClick = (event: MouseEvent) => {
if (ref.current && !ref.current.contains(event.target as Node)) {
callback();
}
};
useEffect(() => {
document.addEventListener("click", handleClick);
return () => {
document.removeEventListener("click", handleClick);
};
});
};
export default useOutsideClickDetector;

View File

@ -11,6 +11,7 @@ import {
PROJECT_MEMBER_DETAIL, PROJECT_MEMBER_DETAIL,
USER_PROJECT_INVITATIONS, USER_PROJECT_INVITATIONS,
PROJECT_VIEW_ENDPOINT, PROJECT_VIEW_ENDPOINT,
PROJECT_MEMBER_ME,
} from "constants/api-routes"; } from "constants/api-routes";
// services // services
import APIService from "lib/services/api.service"; import APIService from "lib/services/api.service";
@ -132,6 +133,16 @@ class ProjectServices extends APIService {
}); });
} }
async projectMemberMe(workspacSlug: string, projectId: string): Promise<IProjectMember> {
return this.get(PROJECT_MEMBER_ME(workspacSlug, projectId))
.then((response) => {
return response?.data;
})
.catch((error) => {
throw error?.response?.data;
});
}
async getProjectMember( async getProjectMember(
workspacSlug: string, workspacSlug: string,
projectId: string, projectId: string,

View File

@ -142,7 +142,7 @@ const ProjectIssues: NextPage = () => {
setFilterIssue, setFilterIssue,
orderBy, orderBy,
filterIssue, filterIssue,
} = useIssuesFilter(projectIssues?.results ?? []); } = useIssuesFilter(projectIssues?.results.filter((p) => p.parent === null) ?? []);
useEffect(() => { useEffect(() => {
if (!isOpen) { if (!isOpen) {

View File

@ -100,6 +100,7 @@ const ProjectSettings: NextPage = () => {
description: formData.description, description: formData.description,
default_assignee: formData.default_assignee, default_assignee: formData.default_assignee,
project_lead: formData.project_lead, project_lead: formData.project_lead,
icon: formData.icon,
}; };
await projectServices await projectServices
.updateProject(activeWorkspace.slug, projectId as string, payload) .updateProject(activeWorkspace.slug, projectId as string, payload)
@ -186,6 +187,7 @@ const ProjectSettings: NextPage = () => {
<form onSubmit={handleSubmit(onSubmit)}> <form onSubmit={handleSubmit(onSubmit)}>
<Tab.Panel> <Tab.Panel>
<GeneralSettings <GeneralSettings
control={control}
register={register} register={register}
errors={errors} errors={errors}
setError={setError} setError={setError}

View File

@ -133,6 +133,7 @@ export type Properties = {
start_date: boolean; start_date: boolean;
target_date: boolean; target_date: boolean;
cycle: boolean; cycle: boolean;
children_count: boolean;
}; };
export interface IIssueLabels { export interface IIssueLabels {

View File

@ -14,6 +14,7 @@ export interface IProject {
slug: string; slug: string;
created_by: string; created_by: string;
updated_by: string; updated_by: string;
icon: string;
} }
type ProjectViewTheme = { type ProjectViewTheme = {

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,26 @@
export const saveRecentEmoji = (emoji: string) => {
const recentEmojis = localStorage.getItem("recentEmojis");
if (recentEmojis) {
const recentEmojisArray = recentEmojis.split(",");
if (recentEmojisArray.includes(emoji)) {
const index = recentEmojisArray.indexOf(emoji);
recentEmojisArray.splice(index, 1);
}
recentEmojisArray.unshift(emoji);
if (recentEmojisArray.length > 18) {
recentEmojisArray.pop();
}
localStorage.setItem("recentEmojis", recentEmojisArray.join(","));
} else {
localStorage.setItem("recentEmojis", emoji);
}
};
export const getRecentEmojis = () => {
const recentEmojis = localStorage.getItem("recentEmojis");
if (recentEmojis) {
const recentEmojisArray = recentEmojis.split(",");
return recentEmojisArray;
}
return [];
};

View File

@ -0,0 +1,125 @@
import React, { useEffect, useState, useRef } from "react";
// headless ui
import { Tab, Transition, Popover } from "@headlessui/react";
// hooks
import useOutsideClickDetector from "lib/hooks/useOutsideClickDetector";
// emoji
import emojis from "./emojis.json";
// helpers
import { getRecentEmojis, saveRecentEmoji } from "./helpers";
// types
import { Props } from "./types";
const tabOptions = [
{
key: "emoji",
title: "Emoji",
},
{
key: "icon",
title: "Icon",
},
];
const EmojiIconPicker: React.FC<Props> = ({ label, value, onChange }) => {
const ref = useRef<HTMLDivElement>(null);
const [isOpen, setIsOpen] = useState(false);
const [recentEmojis, setRecentEmojis] = useState<string[]>([]);
useEffect(() => {
setRecentEmojis(getRecentEmojis());
}, []);
useOutsideClickDetector(ref, () => {
setIsOpen(false);
});
return (
<Popover className="relative" ref={ref}>
<Popover.Button
className="bg-gray-100 px-3 py-1 rounded-full outline-none"
onClick={() => setIsOpen((prev) => !prev)}
>
{label}
</Popover.Button>
<Transition
show={isOpen}
enter="transition ease-out duration-100"
enterFrom="transform opacity-0 scale-95"
enterTo="transform opacity-100 scale-100"
leave="transition ease-in duration-75"
leaveFrom="transform opacity-100 scale-100"
leaveTo="transform opacity-0 scale-95"
>
<Popover.Panel className="absolute z-10 w-80 bg-white rounded-md shadow-lg mt-2">
<div className="w-80 h-80 p-2 bg-white overflow-auto rounded shadow-2xl border">
<Tab.Group as="div" className="w-full h-full flex flex-col">
<Tab.List className="-mx-2 flex justify-around p-1 gap-1 border-b rounded flex-0">
{tabOptions.map((tab) => (
<Tab
key={tab.key}
className={({ selected }) =>
`w-1/2 py-2 transition-colors border-b text-sm font-medium text-center outline-none -my-1 ${
selected ? "border-theme" : "border-transparent"
}`
}
>
{tab.title}
</Tab>
))}
</Tab.List>
<Tab.Panels className="w-full h-full flex-1 overflow-y-auto overflow-x-hidden">
<Tab.Panel className="w-full h-full">
{recentEmojis.length > 0 && (
<div className="py-2 w-full">
<h3 className="text-lg mb-2">Recent Emojis</h3>
<div className="grid grid-cols-9 gap-2">
{recentEmojis.map((emoji) => (
<button
type="button"
className="select-none text-xl"
key={emoji}
onClick={() => {
onChange(emoji);
}}
>
{String.fromCodePoint(parseInt(emoji))}
</button>
))}
</div>
</div>
)}
<div className="py-3">
<h3 className="text-lg mb-2">All Emojis</h3>
<div className="grid grid-cols-9 gap-2">
{emojis.map((emoji) => (
<button
type="button"
className="select-none text-xl"
key={emoji}
onClick={() => {
onChange(emoji);
saveRecentEmoji(emoji);
}}
>
{String.fromCodePoint(parseInt(emoji))}
</button>
))}
</div>
</div>
</Tab.Panel>
<Tab.Panel className="w-full h-full flex flex-col justify-center items-center">
<p>Coming Soon...</p>
</Tab.Panel>
</Tab.Panels>
</Tab.Group>
</div>
</Popover.Panel>
</Transition>
</Popover>
);
};
export default EmojiIconPicker;

View File

@ -0,0 +1,5 @@
export type Props = {
label: string | React.ReactNode;
value: any;
onChange: (data: any) => void;
};

View File

@ -10,3 +10,4 @@ export { default as SearchListbox } from "./search-listbox";
export { default as HeaderButton } from "./HeaderButton"; export { default as HeaderButton } from "./HeaderButton";
export * from "./Breadcrumbs"; export * from "./Breadcrumbs";
export * from "./EmptySpace"; export * from "./EmptySpace";
export { default as EmojiIconPicker } from "./emoji-icon-picker";