From 3e8d0e7426713b2867329c365034fecfc8b89c56 Mon Sep 17 00:00:00 2001 From: MTG2000 Date: Mon, 15 Aug 2022 15:36:13 +0300 Subject: [PATCH 01/53] feat: build "list_project" components & tabs, search users api & mocks --- api/functions/graphql/nexus-typegen.ts | 6 + api/functions/graphql/schema.graphql | 6 + api/functions/graphql/types/project.js | 8 + api/functions/graphql/types/users.js | 26 +- src/App.tsx | 2 + .../Inputs/UsersInput/UsersInput.stories.tsx | 29 ++ .../Inputs/UsersInput/UsersInput.tsx | 154 ++++++++++ .../Inputs/UsersInput/searchUsers.graphql | 8 + .../CapablitiesInput/CapablitiesInput.tsx | 91 ++++++ .../CategoriesInput/CategoriesInput.tsx | 46 +++ .../Components/ExtrasTab/ExtrasTab.tsx | 143 +++++++++ .../FormContainer/FormContainer.tsx | 12 + .../ProjectDetailsTab/ProjectDetailsTab.tsx | 290 ++++++++++++++++++ .../RecruitRolesInput/RecruitRolesInput.tsx | 97 ++++++ .../TeamMembersInput/TeamMembersInput.tsx | 87 ++++++ .../Components/TeamTab/TeamTab.tsx | 140 +++++++++ .../TournamentsInput/TournamentsInput.tsx | 65 ++++ .../pages/ListProjectPage/ListProjectPage.tsx | 103 +++++++ src/graphql/index.tsx | 162 ++++++---- src/mocks/data.ts | 5 +- src/mocks/data/users.ts | 63 +++- src/mocks/handlers.ts | 14 +- src/mocks/resolvers.ts | 4 + src/utils/routing/routes.ts | 3 +- 24 files changed, 1503 insertions(+), 61 deletions(-) create mode 100644 src/Components/Inputs/UsersInput/UsersInput.stories.tsx create mode 100644 src/Components/Inputs/UsersInput/UsersInput.tsx create mode 100644 src/Components/Inputs/UsersInput/searchUsers.graphql create mode 100644 src/features/Projects/pages/ListProjectPage/Components/CapablitiesInput/CapablitiesInput.tsx create mode 100644 src/features/Projects/pages/ListProjectPage/Components/CategoriesInput/CategoriesInput.tsx create mode 100644 src/features/Projects/pages/ListProjectPage/Components/ExtrasTab/ExtrasTab.tsx create mode 100644 src/features/Projects/pages/ListProjectPage/Components/FormContainer/FormContainer.tsx create mode 100644 src/features/Projects/pages/ListProjectPage/Components/ProjectDetailsTab/ProjectDetailsTab.tsx create mode 100644 src/features/Projects/pages/ListProjectPage/Components/RecruitRolesInput/RecruitRolesInput.tsx create mode 100644 src/features/Projects/pages/ListProjectPage/Components/TeamMembersInput/TeamMembersInput.tsx create mode 100644 src/features/Projects/pages/ListProjectPage/Components/TeamTab/TeamTab.tsx create mode 100644 src/features/Projects/pages/ListProjectPage/Components/TournamentsInput/TournamentsInput.tsx create mode 100644 src/features/Projects/pages/ListProjectPage/ListProjectPage.tsx diff --git a/api/functions/graphql/nexus-typegen.ts b/api/functions/graphql/nexus-typegen.ts index 78a7cfd..33e9aff 100644 --- a/api/functions/graphql/nexus-typegen.ts +++ b/api/functions/graphql/nexus-typegen.ts @@ -53,6 +53,7 @@ export interface NexusGenInputs { export interface NexusGenEnums { POST_TYPE: "Bounty" | "Question" | "Story" + TEAM_MEMBER_ROLE: "Admin" | "Maker" VOTE_ITEM_TYPE: "Bounty" | "PostComment" | "Project" | "Question" | "Story" | "User" } @@ -359,6 +360,7 @@ export interface NexusGenFieldTypes { profile: NexusGenRootTypes['User'] | null; // User projectsByCategory: NexusGenRootTypes['Project'][]; // [Project!]! searchProjects: NexusGenRootTypes['Project'][]; // [Project!]! + searchUsers: NexusGenRootTypes['User'][]; // [User!]! } Question: { // field return type author: NexusGenRootTypes['Author']; // Author! @@ -568,6 +570,7 @@ export interface NexusGenFieldTypeNames { profile: 'User' projectsByCategory: 'Project' searchProjects: 'Project' + searchUsers: 'User' } Question: { // field return type name author: 'Author' @@ -725,6 +728,9 @@ export interface NexusGenArgTypes { skip?: number | null; // Int take: number | null; // Int } + searchUsers: { // args + value: string; // String! + } } } diff --git a/api/functions/graphql/schema.graphql b/api/functions/graphql/schema.graphql index b2994b5..965e359 100644 --- a/api/functions/graphql/schema.graphql +++ b/api/functions/graphql/schema.graphql @@ -167,6 +167,7 @@ type Query { profile(id: Int!): User projectsByCategory(category_id: Int!, skip: Int = 0, take: Int = 10): [Project!]! searchProjects(search: String!, skip: Int = 0, take: Int = 50): [Project!]! + searchUsers(value: String!): [User!]! } type Question implements PostBase { @@ -209,6 +210,11 @@ input StoryInputType { title: String! } +enum TEAM_MEMBER_ROLE { + Admin + Maker +} + type Tag { description: String icon: String diff --git a/api/functions/graphql/types/project.js b/api/functions/graphql/types/project.js index a9fed49..a8c563a 100644 --- a/api/functions/graphql/types/project.js +++ b/api/functions/graphql/types/project.js @@ -4,6 +4,7 @@ const { stringArg, extendType, nonNull, + enumType, } = require('nexus') const { prisma } = require('../../../prisma'); @@ -47,6 +48,12 @@ const Project = objectType({ } }) +const TEAM_MEMBER_ROLE = enumType({ + name: 'TEAM_MEMBER_ROLE', + members: ['Admin', 'Maker'], +}); + + const Award = objectType({ name: 'Award', @@ -243,6 +250,7 @@ module.exports = { // Types Project, Award, + TEAM_MEMBER_ROLE, // Queries getProject, allProjects, diff --git a/api/functions/graphql/types/users.js b/api/functions/graphql/types/users.js index 824cc76..d5ba7cc 100644 --- a/api/functions/graphql/types/users.js +++ b/api/functions/graphql/types/users.js @@ -1,6 +1,6 @@ const { prisma } = require('../../../prisma'); -const { objectType, extendType, intArg, nonNull, inputObjectType } = require("nexus"); +const { objectType, extendType, intArg, nonNull, inputObjectType, stringArg } = require("nexus"); const { getUserByPubKey } = require("../../../auth/utils/helperFuncs"); const { removeNulls } = require("./helpers"); @@ -71,6 +71,29 @@ const profile = extendType({ } }) +const searchUsers = extendType({ + type: "Query", + definition(t) { + t.nonNull.list.nonNull.field('searchUsers', { + type: "User", + args: { + value: nonNull(stringArg()) + }, + async resolve(_, { value }) { + return prisma.user.findMany({ + where: { + name: { + contains: value, + mode: "insensitive" + } + }, + }) + } + }) + } +}) + + const UpdateProfileInput = inputObjectType({ name: 'UpdateProfileInput', definition(t) { @@ -125,6 +148,7 @@ module.exports = { // Queries me, profile, + searchUsers, // Mutations updateProfile, } \ No newline at end of file diff --git a/src/App.tsx b/src/App.tsx index f94279c..137c506 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -11,6 +11,7 @@ import ProtectedRoute from "./Components/ProtectedRoute/ProtectedRoute"; import { Helmet } from "react-helmet"; import { NavbarLayout } from "./utils/routing/layouts"; import { Loadable, PAGES_ROUTES } from "./utils/routing"; +import ListProjectPage from "./features/Projects/pages/ListProjectPage/ListProjectPage"; @@ -97,6 +98,7 @@ function App() { } /> } /> } /> + } /> } /> } /> diff --git a/src/Components/Inputs/UsersInput/UsersInput.stories.tsx b/src/Components/Inputs/UsersInput/UsersInput.stories.tsx new file mode 100644 index 0000000..464fce7 --- /dev/null +++ b/src/Components/Inputs/UsersInput/UsersInput.stories.tsx @@ -0,0 +1,29 @@ +import { ComponentStory, ComponentMeta } from '@storybook/react'; +import { WrapForm } from 'src/utils/storybook/decorators'; + +import UsersInput from './UsersInput'; + +export default { + title: 'Shared/Inputs/Users Input', + component: UsersInput, + argTypes: { + backgroundColor: { control: 'color' }, + }, + decorators: [WrapForm({ + defaultValues: { + tags: [{ + title: "Webln" + }] + } + })] +} as ComponentMeta; + + +const Template: ComponentStory = (args) =>
+

+ Search for users: +

+ +
+ +export const Default = Template.bind({}); diff --git a/src/Components/Inputs/UsersInput/UsersInput.tsx b/src/Components/Inputs/UsersInput/UsersInput.tsx new file mode 100644 index 0000000..2dee41d --- /dev/null +++ b/src/Components/Inputs/UsersInput/UsersInput.tsx @@ -0,0 +1,154 @@ + +import AsyncSelect from 'react-select/async'; +import { OnChangeValue, StylesConfig, components, OptionProps } from "react-select"; +import { SearchUsersDocument, SearchUsersQuery, SearchUsersQueryResult } from "src/graphql"; +import { apolloClient } from "src/utils/apollo"; +import Avatar from "src/features/Profiles/Components/Avatar/Avatar"; +import { FiSearch } from 'react-icons/fi'; +import { useState } from 'react'; +import debounce from 'lodash.debounce'; + + + +type User = SearchUsersQuery['searchUsers'][number] + +interface Props { + classes?: { + container?: string + input?: string + } + placeholder?: string, + onSelect?: (selectedUser: User) => void +} + +const fetchOptions = debounce((value, callback: any) => { + apolloClient.query({ + query: SearchUsersDocument, + variables: { + value + } + }) + .then((result) => callback((result as SearchUsersQueryResult).data?.searchUsers ?? [])) + .catch((error: any) => callback(error, null)); +}, 1000); + + + + +const OptionComponent = (props: OptionProps) => { + return ( +
+ + +
+

+ {props.data.name} +

+

+ {props.data.jobTitle} +

+
+
+ +
+ ); +}; + + +const colourStyles: StylesConfig = { + + control: (styles, state) => ({ + ...styles, + padding: '8px 16px', + borderRadius: 12, + // border: 'none', + // boxShadow: 'none', + + ":hover": { + cursor: "pointer" + } + + }), + multiValueRemove: (styles) => ({ + ...styles, + ":hover": { + background: 'none' + } + }), + indicatorsContainer: () => ({ display: 'none' }), + clearIndicator: () => ({ display: 'none' }), + indicatorSeparator: () => ({ display: "none" }), + input: (styles, state) => ({ + ...styles, + " input": { + boxShadow: 'none !important' + }, + }), + multiValue: styles => ({ + ...styles, + padding: '4px 12px', + borderRadius: 48, + fontWeight: 500 + }), + valueContainer: (styles) => ({ + ...styles, + paddingLeft: 0, + paddingRight: 0, + }) +} + + +export default function UsersInput({ + classes, + ...props }: Props) { + + const [inputValue, setInputValue] = useState("") + + const placeholder = props.placeholder ?? Search by username + + const handleChange = (newValue: OnChangeValue,) => { + if (newValue) + props.onSelect?.(newValue); + } + + let emptyMessage = "Type at least 2 characters"; + if (inputValue.length >= 2) + emptyMessage = "Couldn't find any users..." + + + let loadingMessage = "Searching..."; + if (inputValue.length < 2) + loadingMessage = "Type at least 2 characters" + + return ( +
+ loadingMessage} + placeholder={placeholder} + noOptionsMessage={() => emptyMessage} + onChange={handleChange as any} + components={{ + Option: OptionComponent, + // ValueContainer: CustomValueContainer + }} + styles={colourStyles as any} + theme={(theme) => ({ + ...theme, + borderRadius: 8, + colors: { + ...theme.colors, + primary: 'var(--primary)', + }, + })} + /> + {/*
+ {(value as Tag[]).map((tag, idx) => handleRemove(idx)} >{tag.title})} +
*/} +
+ ) +} diff --git a/src/Components/Inputs/UsersInput/searchUsers.graphql b/src/Components/Inputs/UsersInput/searchUsers.graphql new file mode 100644 index 0000000..f503aed --- /dev/null +++ b/src/Components/Inputs/UsersInput/searchUsers.graphql @@ -0,0 +1,8 @@ +query SearchUsers($value: String!) { + searchUsers(value: $value) { + id + name + avatar + jobTitle + } +} diff --git a/src/features/Projects/pages/ListProjectPage/Components/CapablitiesInput/CapablitiesInput.tsx b/src/features/Projects/pages/ListProjectPage/Components/CapablitiesInput/CapablitiesInput.tsx new file mode 100644 index 0000000..d267179 --- /dev/null +++ b/src/features/Projects/pages/ListProjectPage/Components/CapablitiesInput/CapablitiesInput.tsx @@ -0,0 +1,91 @@ +import React from 'react' +import Button from 'src/Components/Button/Button'; +import { useAllCategoriesQuery } from 'src/graphql' +import { random } from 'src/utils/helperFunctions'; + +interface Props { + value: string[]; + onChange?: (v: string[]) => void; +} + +export default function CapablitiesInput(props: Props) { + + + const handleClick = (clickedValue: string) => { + if (props.value.includes(clickedValue)) + props.onChange?.(props.value.filter(v => v !== clickedValue)); + else + props.onChange?.([...props.value, clickedValue]) + } + + + return ( +
+ {false ? + Array(10).fill(0).map((_, idx) => +
+ {"loading category skeleton".slice(random(6, 12))} +
) + : + data.map(item => + ) + } +
+ ) +} + +const data = [ + { + text: 'Mobile', + icon: '๐Ÿ“ฑ' + }, + { + text: 'Web', + icon: '๐Ÿ’ป' + }, + { + text: 'WebLN', + icon: '๐ŸŽ›๏ธ' + }, + { + text: 'LNURL-auth', + icon: '๐Ÿ”‘๏ธ๏ธ' + }, + { + text: 'LNURL-pay', + icon: '๐Ÿ’ธ' + }, + { + text: 'LNURL-channel', + icon: '๐Ÿ•ณ๏ธ๏ธ' + }, + { + text: 'LNURL-withdraw', + icon: '๐ŸŽฌ๏ธ' + }, + { + text: 'BOLT 11', + icon: 'โšก' + }, + { + text: 'BOLT 12', + icon: 'โšก' + }, +] diff --git a/src/features/Projects/pages/ListProjectPage/Components/CategoriesInput/CategoriesInput.tsx b/src/features/Projects/pages/ListProjectPage/Components/CategoriesInput/CategoriesInput.tsx new file mode 100644 index 0000000..c5e8e18 --- /dev/null +++ b/src/features/Projects/pages/ListProjectPage/Components/CategoriesInput/CategoriesInput.tsx @@ -0,0 +1,46 @@ +import React from 'react' +import Button from 'src/Components/Button/Button'; +import { useAllCategoriesQuery } from 'src/graphql' +import { random } from 'src/utils/helperFunctions'; + +interface Props { + value?: number; + onChange?: (v: number) => void; +} + +export default function CategoriesInput(props: Props) { + + const categoriesQuery = useAllCategoriesQuery(); + + + return ( +
+ {categoriesQuery.loading ? + Array(10).fill(0).map((_, idx) => +
+ {"loading category skeleton".slice(random(6, 12))} +
) + : + categoriesQuery.data?.allCategories.map(category => + ) + } +
+ ) +} diff --git a/src/features/Projects/pages/ListProjectPage/Components/ExtrasTab/ExtrasTab.tsx b/src/features/Projects/pages/ListProjectPage/Components/ExtrasTab/ExtrasTab.tsx new file mode 100644 index 0000000..7ae502d --- /dev/null +++ b/src/features/Projects/pages/ListProjectPage/Components/ExtrasTab/ExtrasTab.tsx @@ -0,0 +1,143 @@ +import { Controller, NestedValue, Resolver, SubmitHandler, useForm } from "react-hook-form" +import { NotificationsService } from "src/services/notifications.service"; +import * as yup from "yup"; +import { yupResolver } from "@hookform/resolvers/yup"; +import { usePrompt } from "src/utils/hooks"; +import { toast } from "react-toastify"; +import Card from "src/Components/Card/Card"; +import TeamMembersInput from "../TeamMembersInput/TeamMembersInput"; +import { Team_Member_Role } from "src/graphql"; +import RecruitRolesInput from "../RecruitRolesInput/RecruitRolesInput"; +import TournamentsInput from "../TournamentsInput/TournamentsInput"; + + +export interface IExtrasForm { + launch_status: "wip" | "launched" + tournaments: NestedValue +} + +interface Props { + data?: IExtrasForm, +} + +// type IFormInputs = Props['data']; + +const schema: yup.SchemaOf = yup.object({ + launch_status: yup.mixed().oneOf(['wip', 'launched']).required(), + tournaments: yup.array().required().default([]) +}).required(); + +export default function ExtrasTab({ data }: Props) { + + const { register, formState: { errors, isDirty, }, handleSubmit, reset, control } = useForm({ + defaultValues: { + ...data, + launch_status: 'wip', + tournaments: [] + }, + resolver: yupResolver(schema) as Resolver, + // mode: 'onBlur', + }); + + + + usePrompt('You may have some unsaved changes. You still want to leave?', isDirty) + + + const onSubmit: SubmitHandler = data => { + + const toastId = toast.loading("Saving changes...", NotificationsService.defaultOptions) + const mutate: any = null; + mutate({ + // variables: { + // data: { + // // name: data.name, + // // avatar: data.avatar, + // // jobTitle: data.jobTitle, + // // bio: data.bio, + // // email: data.email, + // // github: data.github, + // // linkedin: data.linkedin, + // // lightning_address: data.lightning_address, + // // location: data.location, + // // twitter: data.twitter, + // // website: data.website, + // } + // }, + onCompleted: () => { + reset(data); + toast.update(toastId, { render: "Saved changes successfully", type: "success", ...NotificationsService.defaultOptions, isLoading: false }); + } + }) + .catch(() => { + toast.update(toastId, { render: "A network error happened", type: "error", ...NotificationsService.defaultOptions, isLoading: false }); + // mutationStatus.reset() + }) + }; + + return ( +
+
+ + + +

๐Ÿš€ Launch status

+

Has this product been launched already, or is it still a work in progress?

+
+
+ +
+

WIP ๐Ÿ› ๏ธ

+

Itโ€™s still a Work In Progress.

+
+
+
+ +
+

Launched ๐Ÿš€

+

The product is ready for launch, or has been launched already.

+
+
+ {errors.launch_status &&

{errors.launch_status?.message}

} +
+
+ + +

โš”๏ธ๏ธ Tournaments

+

Is your application part of a tournament? If so, select the tournament(s) that apply and it will automatically be listed for you.

+
+ ( + + )} + /> + {errors.tournaments &&

{errors.tournaments?.message}

} +
+
+
+
+ {/* reset()} + /> */} +
+
+ ) +} diff --git a/src/features/Projects/pages/ListProjectPage/Components/FormContainer/FormContainer.tsx b/src/features/Projects/pages/ListProjectPage/Components/FormContainer/FormContainer.tsx new file mode 100644 index 0000000..11c7d5d --- /dev/null +++ b/src/features/Projects/pages/ListProjectPage/Components/FormContainer/FormContainer.tsx @@ -0,0 +1,12 @@ + +interface Props { + +} + +export default function FormContainer() { + return ( +
+ +
+ ) +} diff --git a/src/features/Projects/pages/ListProjectPage/Components/ProjectDetailsTab/ProjectDetailsTab.tsx b/src/features/Projects/pages/ListProjectPage/Components/ProjectDetailsTab/ProjectDetailsTab.tsx new file mode 100644 index 0000000..abfa9d2 --- /dev/null +++ b/src/features/Projects/pages/ListProjectPage/Components/ProjectDetailsTab/ProjectDetailsTab.tsx @@ -0,0 +1,290 @@ +import { Controller, NestedValue, Resolver, SubmitHandler, useForm } from "react-hook-form" +import Button from "src/Components/Button/Button"; +import { Project, User, useUpdateProfileAboutMutation } from "src/graphql"; +import { NotificationsService } from "src/services/notifications.service"; +import * as yup from "yup"; +import { yupResolver } from "@hookform/resolvers/yup"; +import Avatar from "src/features/Profiles/Components/Avatar/Avatar"; +import { usePrompt } from "src/utils/hooks"; +import { toast } from "react-toastify"; +import Card from "src/Components/Card/Card"; +import { FaDiscord, FaTwitter } from "react-icons/fa"; +import { FiCamera, FiGithub, FiTwitter } from "react-icons/fi"; +import CategoriesInput from "../CategoriesInput/CategoriesInput"; +import CapablitiesInput from "../CapablitiesInput/CapablitiesInput"; + + +interface IProjectDetails { + name: string + website: string + tagline: string + description: string + thumbnail_image: string + cover_image: string + + twitter: string + discord: string + github: string + + category_id: number + + capabilities: NestedValue + + screenshots: NestedValue + +} + +interface Props { + data?: IProjectDetails, + onClose?: () => void; +} + +// type IFormInputs = Props['data']; + +const schema: yup.SchemaOf = yup.object({ + name: yup.string().trim().required().min(2), + website: yup.string().trim().url().required(), + tagline: yup.string().trim().required().min(10), + description: yup.string().trim().required().min(50, 'Write at least 10 words descriping your project'), + thumbnail_image: yup.string().url().required(), + cover_image: yup.string().url().required(), + + twitter: yup.string().ensure(), + discord: yup.string().ensure(), + github: yup.string().ensure(), + + category_id: yup.number().required(), + + capabilities: yup.array().of(yup.string().required()), + + screenshots: yup.array().of(yup.string().required()), +}).required(); + +export default function ProjectDetailsTab({ data, onClose }: Props) { + + const { register, formState: { errors, isDirty, }, handleSubmit, reset, control } = useForm({ + defaultValues: { + ...data, + capabilities: data?.capabilities ?? [] + }, + resolver: yupResolver(schema) as Resolver, + // mode: 'onBlur', + }); + + const [mutate, mutationStatus] = useUpdateProfileAboutMutation(); + + + + usePrompt('You may have some unsaved changes. You still want to leave?', isDirty) + + + const onSubmit: SubmitHandler = data => { + + const toastId = toast.loading("Saving changes...", NotificationsService.defaultOptions) + + mutate({ + // variables: { + // data: { + // // name: data.name, + // // avatar: data.avatar, + // // jobTitle: data.jobTitle, + // // bio: data.bio, + // // email: data.email, + // // github: data.github, + // // linkedin: data.linkedin, + // // lightning_address: data.lightning_address, + // // location: data.location, + // // twitter: data.twitter, + // // website: data.website, + // } + // }, + onCompleted: () => { + reset(data); + toast.update(toastId, { render: "Saved changes successfully", type: "success", ...NotificationsService.defaultOptions, isLoading: false }); + } + }) + .catch(() => { + toast.update(toastId, { render: "A network error happened", type: "error", ...NotificationsService.defaultOptions, isLoading: false }); + mutationStatus.reset() + }) + }; + + return ( +
+
+ +
+
+ {/* */} +
+ + Add image +
+
+
+
+

+ Project name* +

+
+ +
+ {errors.name &&

+ {errors.name.message} +

} +

+ Project link* +

+
+ +
+ {errors.website &&

+ {errors.website.message} +

} +

+ Tagline* +

+
+ +
+ {errors.tagline &&

+ {errors.tagline.message} +

} +

+ Description* +

+
+