mirror of
https://github.com/kamranahmedse/developer-roadmap.git
synced 2026-03-12 17:51:53 +08:00
Merge branch 'master' into chore/update-progress
This commit is contained in:
@@ -18,6 +18,7 @@ export type PageType = {
|
||||
group: string;
|
||||
icon?: string;
|
||||
isProtected?: boolean;
|
||||
metadata?: Record<string, any>;
|
||||
};
|
||||
|
||||
const defaultPages: PageType[] = [
|
||||
|
||||
47
src/components/CreateTeam/NotDropdown.tsx
Normal file
47
src/components/CreateTeam/NotDropdown.tsx
Normal file
@@ -0,0 +1,47 @@
|
||||
import ChevronDownIcon from '../../icons/chevron-down.svg';
|
||||
|
||||
type NotDropdownProps = {
|
||||
onClick: () => void;
|
||||
selectedCount: number;
|
||||
singularName: string;
|
||||
pluralName: string;
|
||||
};
|
||||
|
||||
export function NotDropdown(props: NotDropdownProps) {
|
||||
const { onClick, selectedCount, singularName, pluralName } = props;
|
||||
|
||||
const singularOrPlural = selectedCount === 1 ? singularName : pluralName;
|
||||
|
||||
return (
|
||||
<div
|
||||
className="flex cursor-text items-center justify-between rounded-md border border-gray-300 px-3 py-2.5 hover:border-gray-400/50 hover:bg-gray-50"
|
||||
role="button"
|
||||
onClick={onClick}
|
||||
>
|
||||
{selectedCount > 0 && (
|
||||
<div className="flex flex-col">
|
||||
<p className="mb-1.5 text-base font-medium text-gray-800">
|
||||
{selectedCount} {singularOrPlural} selected
|
||||
</p>
|
||||
<p className="text-sm text-gray-400">
|
||||
Click to add or change selection
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{selectedCount === 0 && (
|
||||
<div className="flex flex-col">
|
||||
<p className="text-base text-gray-400">
|
||||
Click to select {pluralName}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<img
|
||||
alt={singularName}
|
||||
src={ChevronDownIcon}
|
||||
className={'relative top-[1px] h-[17px] w-[17px] opacity-40'}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,11 +1,12 @@
|
||||
import { useEffect, useState } from 'preact/hooks';
|
||||
import { SearchSelector } from '../SearchSelector';
|
||||
import { httpGet, httpPut } from '../../lib/http';
|
||||
import type { PageType } from '../CommandMenu/CommandMenu';
|
||||
import SearchIcon from '../../icons/search.svg';
|
||||
import ChevronDownIcon from '../../icons/chevron-down.svg';
|
||||
import { pageProgressMessage } from '../../stores/page';
|
||||
import type { TeamDocument } from './CreateTeamForm';
|
||||
import { UpdateTeamResourceModal } from './UpdateTeamResourceModal';
|
||||
import { SelectRoadmapModal } from './SelectRoadmapModal';
|
||||
import { NotDropdown } from './NotDropdown';
|
||||
|
||||
export type TeamResourceConfig = {
|
||||
resourceId: string;
|
||||
@@ -14,14 +15,15 @@ export type TeamResourceConfig = {
|
||||
}[];
|
||||
|
||||
type RoadmapSelectorProps = {
|
||||
team: TeamDocument;
|
||||
teamId: string;
|
||||
teamResourceConfig: TeamResourceConfig;
|
||||
setTeamResourceConfig: (config: TeamResourceConfig) => void;
|
||||
};
|
||||
|
||||
export function RoadmapSelector(props: RoadmapSelectorProps) {
|
||||
const { team, teamResourceConfig = [], setTeamResourceConfig } = props;
|
||||
const { teamId, teamResourceConfig = [], setTeamResourceConfig } = props;
|
||||
|
||||
const [showSelectRoadmapModal, setShowSelectRoadmapModal] = useState(false);
|
||||
const [allRoadmaps, setAllRoadmaps] = useState<PageType[]>([]);
|
||||
const [changingRoadmapId, setChangingRoadmapId] = useState<string>('');
|
||||
const [error, setError] = useState<string>('');
|
||||
@@ -50,15 +52,15 @@ export function RoadmapSelector(props: RoadmapSelectorProps) {
|
||||
}
|
||||
|
||||
async function deleteResource(roadmapId: string) {
|
||||
if (!team?._id) {
|
||||
if (!teamId) {
|
||||
return;
|
||||
}
|
||||
|
||||
pageProgressMessage.set(`Deleting resource`);
|
||||
const { error, response } = await httpPut<TeamResourceConfig>(
|
||||
`${import.meta.env.PUBLIC_API_URL}/v1-delete-team-resource-config/${
|
||||
team._id
|
||||
}`,
|
||||
`${
|
||||
import.meta.env.PUBLIC_API_URL
|
||||
}/v1-delete-team-resource-config/${teamId}`,
|
||||
{
|
||||
resourceId: roadmapId,
|
||||
resourceType: 'roadmap',
|
||||
@@ -82,17 +84,17 @@ export function RoadmapSelector(props: RoadmapSelectorProps) {
|
||||
}
|
||||
|
||||
async function addTeamResource(roadmapId: string) {
|
||||
if (!team?._id) {
|
||||
if (!teamId) {
|
||||
return;
|
||||
}
|
||||
|
||||
pageProgressMessage.set(`Adding roadmap to team`);
|
||||
const { error, response } = await httpPut<TeamResourceConfig>(
|
||||
`${import.meta.env.PUBLIC_API_URL}/v1-update-team-resource-config/${
|
||||
team._id
|
||||
}`,
|
||||
`${
|
||||
import.meta.env.PUBLIC_API_URL
|
||||
}/v1-update-team-resource-config/${teamId}`,
|
||||
{
|
||||
teamId: team._id,
|
||||
teamId: teamId,
|
||||
resourceId: roadmapId,
|
||||
resourceType: 'roadmap',
|
||||
removed: [],
|
||||
@@ -118,7 +120,7 @@ export function RoadmapSelector(props: RoadmapSelectorProps) {
|
||||
onClose={() => setChangingRoadmapId('')}
|
||||
resourceId={changingRoadmapId}
|
||||
resourceType={'roadmap'}
|
||||
teamId={team?._id!}
|
||||
teamId={teamId}
|
||||
setTeamResourceConfig={setTeamResourceConfig}
|
||||
defaultRemovedItems={
|
||||
teamResourceConfig.find((c) => c.resourceId === changingRoadmapId)
|
||||
@@ -126,43 +128,38 @@ export function RoadmapSelector(props: RoadmapSelectorProps) {
|
||||
}
|
||||
/>
|
||||
)}
|
||||
{showSelectRoadmapModal && (
|
||||
<SelectRoadmapModal
|
||||
onClose={() => setShowSelectRoadmapModal(false)}
|
||||
teamResourceConfig={teamResourceConfig}
|
||||
allRoadmaps={allRoadmaps}
|
||||
teamId={teamId}
|
||||
onRoadmapAdd={(roadmapId) => {
|
||||
addTeamResource(roadmapId).finally(() => {
|
||||
pageProgressMessage.set('');
|
||||
});
|
||||
}}
|
||||
onRoadmapRemove={(roadmapId) => {
|
||||
onRemove(roadmapId).finally(() => {});
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
<SearchSelector
|
||||
placeholder={`Search Roadmaps ..`}
|
||||
onSelect={(option) => {
|
||||
const roadmapId = option.value;
|
||||
addTeamResource(roadmapId).finally(() => {
|
||||
pageProgressMessage.set('');
|
||||
});
|
||||
}}
|
||||
options={allRoadmaps
|
||||
.filter((roadmap) => {
|
||||
return !teamResourceConfig
|
||||
.map((c) => c.resourceId)
|
||||
.includes(roadmap.id);
|
||||
})
|
||||
.map((roadmap) => ({
|
||||
value: roadmap.id,
|
||||
label: roadmap.title,
|
||||
}))}
|
||||
searchInputId={'roadmap-input'}
|
||||
inputClassName="mt-2 block w-full rounded-md border px-3 py-2 shadow-sm outline-none placeholder:text-gray-400 focus:ring-2 focus:ring-black focus:ring-offset-1"
|
||||
/>
|
||||
<div className="mt-3">
|
||||
<NotDropdown
|
||||
onClick={() => {
|
||||
setShowSelectRoadmapModal(true);
|
||||
}}
|
||||
selectedCount={teamResourceConfig.length}
|
||||
singularName={'roadmap'}
|
||||
pluralName={'roadmaps'}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{!teamResourceConfig.length && (
|
||||
<div className="mt-4 rounded-md border px-4 py-12 text-center text-sm text-gray-700">
|
||||
<img
|
||||
alt={'search'}
|
||||
src={SearchIcon}
|
||||
className={'mx-auto mb-5 h-[42px] w-[42px] opacity-10'}
|
||||
/>
|
||||
<span className="block text-lg font-semibold text-black">
|
||||
No roadmaps selected.
|
||||
</span>
|
||||
<p className={'text-sm text-gray-400'}>
|
||||
Please search and add roadmaps from above
|
||||
</p>
|
||||
</div>
|
||||
<p className={'mb-3 mt-2 text-base text-gray-400'}>
|
||||
No roadmaps selected.
|
||||
</p>
|
||||
)}
|
||||
|
||||
{teamResourceConfig.length > 0 && (
|
||||
|
||||
152
src/components/CreateTeam/SelectRoadmapModal.tsx
Normal file
152
src/components/CreateTeam/SelectRoadmapModal.tsx
Normal file
@@ -0,0 +1,152 @@
|
||||
import { useEffect, useRef, useState } from 'preact/hooks';
|
||||
import { useKeydown } from '../../hooks/use-keydown';
|
||||
import { useOutsideClick } from '../../hooks/use-outside-click';
|
||||
import type { PageType } from '../CommandMenu/CommandMenu';
|
||||
import type { TeamResourceConfig } from './RoadmapSelector';
|
||||
import CloseIcon from '../../icons/close.svg';
|
||||
import { SelectRoadmapModalItem } from './SelectRoadmapModalItem';
|
||||
|
||||
export type SelectRoadmapModalProps = {
|
||||
teamId: string;
|
||||
allRoadmaps: PageType[];
|
||||
onClose: () => void;
|
||||
teamResourceConfig: TeamResourceConfig;
|
||||
onRoadmapAdd: (roadmapId: string) => void;
|
||||
onRoadmapRemove: (roadmapId: string) => void;
|
||||
};
|
||||
|
||||
export function SelectRoadmapModal(props: SelectRoadmapModalProps) {
|
||||
const {
|
||||
onClose,
|
||||
allRoadmaps,
|
||||
onRoadmapAdd,
|
||||
onRoadmapRemove,
|
||||
teamResourceConfig,
|
||||
} = props;
|
||||
const popupBodyEl = useRef<HTMLDivElement>(null);
|
||||
const searchInputEl = useRef<HTMLInputElement>(null);
|
||||
|
||||
const [searchResults, setSearchResults] = useState<PageType[]>(allRoadmaps);
|
||||
const [searchText, setSearchText] = useState('');
|
||||
|
||||
useKeydown('Escape', () => {
|
||||
onClose();
|
||||
});
|
||||
|
||||
useOutsideClick(popupBodyEl, () => {
|
||||
onClose();
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (!searchInputEl.current) {
|
||||
return;
|
||||
}
|
||||
|
||||
searchInputEl.current.focus();
|
||||
}, [searchInputEl]);
|
||||
|
||||
useEffect(() => {
|
||||
if (searchText.length === 0) {
|
||||
setSearchResults(allRoadmaps);
|
||||
return;
|
||||
}
|
||||
|
||||
const searchResults = allRoadmaps.filter((roadmap) => {
|
||||
return (
|
||||
roadmap.title.toLowerCase().includes(searchText.toLowerCase()) ||
|
||||
roadmap.id.toLowerCase().includes(searchText.toLowerCase())
|
||||
);
|
||||
});
|
||||
setSearchResults(searchResults);
|
||||
}, [searchText, allRoadmaps]);
|
||||
|
||||
const roleBasedRoadmaps = searchResults.filter((roadmap) =>
|
||||
roadmap?.metadata?.tags?.includes('role-roadmap')
|
||||
);
|
||||
const skillBasedRoadmaps = searchResults.filter((roadmap) =>
|
||||
roadmap?.metadata?.tags?.includes('skill-roadmap')
|
||||
);
|
||||
|
||||
return (
|
||||
<div class="fixed left-0 right-0 top-0 z-50 h-full items-center justify-center overflow-y-auto overflow-x-hidden overscroll-contain bg-black/50">
|
||||
<div class="relative mx-auto h-full w-full max-w-2xl p-4 md:h-auto">
|
||||
<div
|
||||
ref={popupBodyEl}
|
||||
class="popup-body relative mt-4 overflow-hidden rounded-lg bg-white shadow"
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
className="popup-close absolute right-2.5 top-3 ml-auto inline-flex items-center rounded-lg bg-transparent p-1.5 text-sm text-gray-400 hover:bg-gray-100 hover:text-gray-900"
|
||||
onClick={onClose}
|
||||
>
|
||||
<img alt={'close'} src={CloseIcon} className="h-4 w-4" />
|
||||
<span class="sr-only">Close modal</span>
|
||||
</button>
|
||||
<input
|
||||
ref={searchInputEl}
|
||||
type="text"
|
||||
placeholder="Search roadmaps"
|
||||
className="block w-full border-b px-5 pb-3.5 pt-4 outline-none placeholder:text-gray-400"
|
||||
value={searchText}
|
||||
onInput={(e) => setSearchText((e.target as HTMLInputElement).value)}
|
||||
/>
|
||||
<div className="min-h-[200px] p-4">
|
||||
<span className="block pb-3 text-xs uppercase text-gray-400">
|
||||
Role Based Roadmaps
|
||||
</span>
|
||||
{roleBasedRoadmaps.length === 0 && (
|
||||
<p className="mb-1 flex h-full items-start text-sm italic text-gray-400"></p>
|
||||
)}
|
||||
{roleBasedRoadmaps.length > 0 && (
|
||||
<div className="mb-5 flex flex-wrap items-center gap-2">
|
||||
{roleBasedRoadmaps.map((roadmap) => {
|
||||
const isSelected = !!teamResourceConfig.find(
|
||||
(r) => r.resourceId === roadmap.id
|
||||
);
|
||||
|
||||
return (
|
||||
<SelectRoadmapModalItem
|
||||
title={roadmap.title}
|
||||
isSelected={isSelected}
|
||||
onClick={() => {
|
||||
if (isSelected) {
|
||||
onRoadmapRemove(roadmap.id);
|
||||
} else {
|
||||
onRoadmapAdd(roadmap.id);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
<span className="block pb-3 text-xs uppercase text-gray-400">
|
||||
Skill Based Roadmaps
|
||||
</span>
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
{skillBasedRoadmaps.map((roadmap) => {
|
||||
const isSelected = !!teamResourceConfig.find(
|
||||
(r) => r.resourceId === roadmap.id
|
||||
);
|
||||
|
||||
return (
|
||||
<SelectRoadmapModalItem
|
||||
title={roadmap.title}
|
||||
isSelected={isSelected}
|
||||
onClick={() => {
|
||||
if (isSelected) {
|
||||
onRoadmapRemove(roadmap.id);
|
||||
} else {
|
||||
onRoadmapAdd(roadmap.id);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
34
src/components/CreateTeam/SelectRoadmapModalItem.tsx
Normal file
34
src/components/CreateTeam/SelectRoadmapModalItem.tsx
Normal file
@@ -0,0 +1,34 @@
|
||||
import type { SelectRoadmapModalProps } from './SelectRoadmapModal';
|
||||
|
||||
type SelectRoadmapModalItemProps = {
|
||||
title: string;
|
||||
isSelected: boolean;
|
||||
onClick: () => void;
|
||||
};
|
||||
|
||||
export function SelectRoadmapModalItem(props: SelectRoadmapModalItemProps) {
|
||||
const { isSelected, onClick, title } = props;
|
||||
return (
|
||||
<button
|
||||
className={`group flex min-h-[35px] items-stretch overflow-hidden rounded-md text-sm ${
|
||||
!isSelected
|
||||
? 'border border-gray-300 hover:bg-gray-100'
|
||||
: 'bg-black text-white transition-colors hover:bg-gray-700'
|
||||
}`}
|
||||
onClick={onClick}
|
||||
>
|
||||
<span className="flex items-center px-3">{title}</span>
|
||||
{isSelected && (
|
||||
<span className="flex items-center bg-gray-700 px-3 text-xs text-white transition-colors">
|
||||
×
|
||||
</span>
|
||||
)}
|
||||
|
||||
{!isSelected && (
|
||||
<span className="flex items-center bg-gray-100 px-2.5 text-xs text-gray-500">
|
||||
+
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
@@ -87,10 +87,10 @@ export function Step0(props: Step0Props) {
|
||||
validTeamType.value === selectedTeamType ? 'opacity-100' : ''
|
||||
}`}
|
||||
/>
|
||||
<span className="mb-1.5 block text-2xl font-bold">
|
||||
<span className="mb-2 block text-2xl font-bold">
|
||||
{validTeamType.label}
|
||||
</span>
|
||||
<span className="text-sm text-gray-500">
|
||||
<span className="text-sm text-gray-500 leading-[21px]">
|
||||
{validTeamType.description}
|
||||
</span>
|
||||
</button>
|
||||
|
||||
@@ -5,10 +5,12 @@ import type { TeamDocument } from './CreateTeamForm';
|
||||
import { NextButton } from './NextButton';
|
||||
|
||||
export const validTeamSizes = [
|
||||
'0-1',
|
||||
'2-10',
|
||||
'11-50',
|
||||
'51-200',
|
||||
'1-5',
|
||||
'6-10',
|
||||
'11-25',
|
||||
'26-50',
|
||||
'51-100',
|
||||
'101-200',
|
||||
'201-500',
|
||||
'501-1000',
|
||||
'1000+',
|
||||
@@ -134,7 +136,7 @@ export function Step1(props: Step1Props) {
|
||||
autofocus={true}
|
||||
id="name"
|
||||
className="mt-2 block w-full rounded-lg border border-gray-300 px-3 py-2 shadow-sm outline-none placeholder:text-gray-400 focus:ring-2 focus:ring-black focus:ring-offset-1"
|
||||
placeholder="roadmap.sh"
|
||||
placeholder="Roadmap Inc."
|
||||
disabled={isLoading}
|
||||
required
|
||||
value={name}
|
||||
@@ -167,7 +169,7 @@ export function Step1(props: Step1Props) {
|
||||
{selectedTeamType === 'company' && (
|
||||
<div className="mt-4 flex w-full flex-col">
|
||||
<label for="website" className="text-sm leading-none text-slate-500">
|
||||
LinkedIn URL
|
||||
Company LinkedIn URL
|
||||
</label>
|
||||
<input
|
||||
type="url"
|
||||
@@ -206,7 +208,7 @@ export function Step1(props: Step1Props) {
|
||||
for="team-size"
|
||||
className='text-sm leading-none text-slate-500 after:text-red-400 after:content-["*"]'
|
||||
>
|
||||
Company Size
|
||||
Tech Team Size
|
||||
</label>
|
||||
<select
|
||||
name="team-size"
|
||||
@@ -229,6 +231,12 @@ export function Step1(props: Step1Props) {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{error && (
|
||||
<div className="mt-4 flex w-full flex-col">
|
||||
<span className="text-sm text-red-500">{error}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="mt-4 flex flex-row items-center justify-between gap-2">
|
||||
<button
|
||||
type="button"
|
||||
|
||||
@@ -17,15 +17,14 @@ export function Step2(props: Step2Props) {
|
||||
<>
|
||||
<div className="mt-4 flex w-full flex-col">
|
||||
<div className="mb-1 mt-2">
|
||||
<h2 className="mb-2 text-2xl font-bold">Select Roadmaps</h2>
|
||||
<h2 className="mb-1.5 text-2xl font-bold">Select Roadmaps</h2>
|
||||
<p className="text-sm text-gray-700">
|
||||
Picks the roadmaps to be made available to your team for tracking.
|
||||
You can always add more later.
|
||||
You can always add and customize your roadmaps later.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<RoadmapSelector
|
||||
team={team}
|
||||
teamId={team._id!}
|
||||
teamResourceConfig={teamResourceConfig}
|
||||
setTeamResourceConfig={setTeamResourceConfig}
|
||||
/>
|
||||
|
||||
@@ -95,15 +95,14 @@ export function TeamDropdown() {
|
||||
{pendingTeamIds.length}
|
||||
</span>
|
||||
)}
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="inline-grid grid-cols-[16px_auto] items-center gap-1.5 mr-1.5">
|
||||
{isLoading && <Spinner className="h-4 w-4" isDualRing={false} />}
|
||||
{!isLoading && (
|
||||
<img
|
||||
src={
|
||||
selectedAvatar
|
||||
? `${
|
||||
import.meta.env.PUBLIC_AVATAR_BASE_URL
|
||||
}/${selectedAvatar}`
|
||||
? `${import.meta.env.PUBLIC_AVATAR_BASE_URL
|
||||
}/${selectedAvatar}`
|
||||
: '/images/default-avatar.png'
|
||||
}
|
||||
alt=""
|
||||
@@ -140,28 +139,18 @@ export function TeamDropdown() {
|
||||
pageLink = `/team/progress?t=${team._id}`;
|
||||
}
|
||||
|
||||
if (team.roadmaps.length === 0) {
|
||||
pageLink = `/team/new?t=${team._id}&s=2`;
|
||||
}
|
||||
|
||||
return (
|
||||
<li>
|
||||
<a
|
||||
className="flex w-full cursor-pointer items-center gap-2 rounded p-2 text-sm font-medium text-slate-100 hover:bg-slate-700"
|
||||
href={`${pageLink}`}
|
||||
>
|
||||
<span className="flex-grow truncate">{team.name}</span>
|
||||
<span className="flex-grow min-w-0 truncate">{team.name}</span>
|
||||
{pendingTeamIds.includes(team._id) && (
|
||||
<span className="flex rounded-md bg-red-500 px-2 text-xs text-white">
|
||||
Invite
|
||||
</span>
|
||||
)}
|
||||
|
||||
{team.roadmaps.length === 0 && (
|
||||
<span className="flex rounded-md bg-gray-500 px-2 text-xs text-white">
|
||||
Draft
|
||||
</span>
|
||||
)}
|
||||
</a>
|
||||
</li>
|
||||
);
|
||||
|
||||
@@ -163,8 +163,8 @@ export function TeamMembersPage() {
|
||||
<MemberRoleBadge role={member.role} />
|
||||
</span>
|
||||
<div className="flex items-center">
|
||||
<h3 className="flex items-center font-medium">
|
||||
{member.name}
|
||||
<h3 className="inline-grid grid-cols-[auto_auto] items-center font-medium">
|
||||
<span className="truncate">{member.name}</span>
|
||||
{member.userId === user?.id && (
|
||||
<span className="ml-2 hidden text-xs font-normal text-blue-500 sm:inline">
|
||||
You
|
||||
|
||||
@@ -3,6 +3,7 @@ import type { GroupByRoadmap, TeamMember } from './TeamProgressPage';
|
||||
import { MemberProgressModal } from './MemberProgressModal';
|
||||
import { getUrlParams } from '../../lib/browser';
|
||||
import ExternalLinkIcon from '../../icons/external-link.svg';
|
||||
import { useAuth } from '../../hooks/use-auth';
|
||||
|
||||
type GroupRoadmapItemProps = {
|
||||
roadmap: GroupByRoadmap;
|
||||
@@ -11,6 +12,7 @@ type GroupRoadmapItemProps = {
|
||||
export function GroupRoadmapItem(props: GroupRoadmapItemProps) {
|
||||
const { members, resourceTitle, resourceId } = props.roadmap;
|
||||
const { t: teamId } = getUrlParams();
|
||||
const user = useAuth();
|
||||
|
||||
const [showAll, setShowAll] = useState(false);
|
||||
const [detailResourceId, setDetailResourceId] = useState<string | null>(null);
|
||||
@@ -49,10 +51,15 @@ export function GroupRoadmapItem(props: GroupRoadmapItemProps) {
|
||||
</div>
|
||||
<div className="relative flex grow flex-col space-y-2 p-3">
|
||||
{(showAll ? members : members.slice(0, 4)).map((member) => {
|
||||
if (!member.progress) return null;
|
||||
const isMyProgress = user?.email === member?.member?.email;
|
||||
|
||||
if (!member.progress) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<button
|
||||
className="group relative w-full overflow-hidden rounded-md border p-2 hover:border-gray-300 hover:text-black focus:outline-none"
|
||||
className={`group relative w-full overflow-hidden rounded-md border p-2 hover:border-gray-300 hover:text-black focus:outline-none ${isMyProgress ? 'border-green-500 hover:border-green-600' : ''}`}
|
||||
key={member?.member._id}
|
||||
onClick={() => {
|
||||
setDetailResourceId(member?.progress?.resourceId!);
|
||||
@@ -60,7 +67,7 @@ export function GroupRoadmapItem(props: GroupRoadmapItemProps) {
|
||||
}}
|
||||
>
|
||||
<span className="relative z-10 flex items-center justify-between gap-1 text-sm">
|
||||
<span className="inline-grid grid-cols-[20px_auto] gap-2">
|
||||
<span className="inline-grid grid-cols-[20px_auto] gap-3">
|
||||
<img
|
||||
src={
|
||||
member.member.avatar
|
||||
@@ -72,14 +79,16 @@ export function GroupRoadmapItem(props: GroupRoadmapItemProps) {
|
||||
alt={member.member.name || ''}
|
||||
className="h-5 w-5 shrink-0 rounded-full"
|
||||
/>
|
||||
<span className="truncate">{member?.member?.name}</span>
|
||||
<span className="inline-grid grid-cols-[auto,32px] items-center">
|
||||
<span className="truncate mr-[5px]">{member?.member?.name}</span>
|
||||
</span>
|
||||
</span>
|
||||
<span className="shrink-0 text-xs text-gray-400">
|
||||
{member?.progress?.done} / {member?.progress?.total}
|
||||
</span>
|
||||
</span>
|
||||
<span
|
||||
className="absolute inset-0 bg-gray-100 group-hover:bg-gray-200"
|
||||
className={`absolute inset-0 ${isMyProgress ? 'bg-green-100 group-hover:bg-green-200' : 'bg-gray-100 group-hover:bg-gray-200'}`}
|
||||
style={{
|
||||
width: `${
|
||||
(member?.progress?.done / member?.progress?.total) * 100
|
||||
|
||||
@@ -5,9 +5,10 @@ import { MemberProgressModal } from './MemberProgressModal';
|
||||
type MemberProgressItemProps = {
|
||||
teamId: string;
|
||||
member: TeamMember;
|
||||
isMyProgress?: boolean;
|
||||
};
|
||||
export function MemberProgressItem(props: MemberProgressItemProps) {
|
||||
const { member, teamId } = props;
|
||||
const { member, teamId, isMyProgress = false } = props;
|
||||
|
||||
const memberProgress = member?.progress?.sort((a, b) => {
|
||||
return b.done - a.done;
|
||||
@@ -31,10 +32,10 @@ export function MemberProgressItem(props: MemberProgressItemProps) {
|
||||
)}
|
||||
|
||||
<div
|
||||
className="flex h-full min-h-[270px] flex-col rounded-md border"
|
||||
className={`flex h-full min-h-[270px] flex-col overflow-hidden rounded-md border`}
|
||||
key={member._id}
|
||||
>
|
||||
<div className="flex items-center gap-3 border-b p-3">
|
||||
<div className={`relative flex items-center gap-3 border-b p-3`}>
|
||||
<img
|
||||
src={
|
||||
member.avatar
|
||||
@@ -44,8 +45,18 @@ export function MemberProgressItem(props: MemberProgressItemProps) {
|
||||
alt={member.name || ''}
|
||||
className="h-8 w-8 rounded-full"
|
||||
/>
|
||||
<div className="inline-grid">
|
||||
<h3 className="truncate font-medium">{member.name}</h3>
|
||||
<div className="inline-grid w-full">
|
||||
{!isMyProgress && (
|
||||
<h3 className="truncate font-medium">{member.name}</h3>
|
||||
)}
|
||||
{isMyProgress && (
|
||||
<div className="inline-grid grid-cols-[auto,32px] items-center gap-1.5">
|
||||
<h3 className="truncate font-medium">{member.name}</h3>
|
||||
<span className="rounded-md bg-red-500 py-0.5 px-1 text-xs text-white">
|
||||
You
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
<p className="truncate text-sm text-gray-500">{member.email}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -7,8 +7,8 @@ import { useToast } from '../../hooks/use-toast';
|
||||
import { useStore } from '@nanostores/preact';
|
||||
import { $currentTeam } from '../../stores/team';
|
||||
import { GroupRoadmapItem } from './GroupRoadmapItem';
|
||||
import { setUrlParams } from '../../lib/browser';
|
||||
import { getUrlParams } from '../../lib/browser';
|
||||
import { getUrlParams, setUrlParams } from '../../lib/browser';
|
||||
import { useAuth } from '../../hooks/use-auth';
|
||||
|
||||
export type UserProgress = {
|
||||
resourceTitle: string;
|
||||
@@ -54,6 +54,7 @@ export function TeamProgressPage() {
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const toast = useToast();
|
||||
const currentTeam = useStore($currentTeam);
|
||||
const user = useAuth();
|
||||
|
||||
const [teamMembers, setTeamMembers] = useState<TeamMember[]>([]);
|
||||
const [selectedGrouping, setSelectedGrouping] = useState<
|
||||
@@ -69,7 +70,17 @@ export function TeamProgressPage() {
|
||||
return;
|
||||
}
|
||||
|
||||
setTeamMembers(response);
|
||||
setTeamMembers(
|
||||
response.sort((a, b) => {
|
||||
if (a.email === user?.email) {
|
||||
return -1;
|
||||
}
|
||||
if (b.email === user?.email) {
|
||||
return 1;
|
||||
}
|
||||
return 0;
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
@@ -159,7 +170,11 @@ export function TeamProgressPage() {
|
||||
{selectedGrouping === 'member' && (
|
||||
<div className="grid gap-4 sm:grid-cols-2">
|
||||
{teamMembers.map((member) => (
|
||||
<MemberProgressItem teamId={teamId} member={member} />
|
||||
<MemberProgressItem
|
||||
teamId={teamId}
|
||||
member={member}
|
||||
isMyProgress={member?.email === user?.email}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -5,13 +5,14 @@ import type { TeamResourceConfig } from './CreateTeam/RoadmapSelector';
|
||||
import { httpGet, httpPut } from '../lib/http';
|
||||
import { pageProgressMessage } from '../stores/page';
|
||||
import ExternalLinkIcon from '../icons/external-link.svg';
|
||||
import RoadmapIcon from '../icons/roadmap.svg';
|
||||
import PlusIcon from '../icons/plus.svg';
|
||||
import type { PageType } from './CommandMenu/CommandMenu';
|
||||
import { UpdateTeamResourceModal } from './CreateTeam/UpdateTeamResourceModal';
|
||||
import { AddTeamRoadmap } from './AddTeamRoadmap';
|
||||
import { useStore } from '@nanostores/preact';
|
||||
import { $canManageCurrentTeam } from '../stores/team';
|
||||
import {useToast} from "../hooks/use-toast";
|
||||
import { useToast } from '../hooks/use-toast';
|
||||
import { SelectRoadmapModal } from './CreateTeam/SelectRoadmapModal';
|
||||
|
||||
export function TeamRoadmaps() {
|
||||
const { t: teamId } = getUrlParams();
|
||||
@@ -20,6 +21,7 @@ export function TeamRoadmaps() {
|
||||
|
||||
const toast = useToast();
|
||||
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [removingRoadmapId, setRemovingRoadmapId] = useState<string>('');
|
||||
const [isAddingRoadmap, setIsAddingRoadmap] = useState(false);
|
||||
const [changingRoadmapId, setChangingRoadmapId] = useState<string>('');
|
||||
@@ -83,12 +85,14 @@ export function TeamRoadmaps() {
|
||||
return;
|
||||
}
|
||||
|
||||
setIsLoading(true);
|
||||
Promise.all([
|
||||
loadTeam(teamId),
|
||||
loadTeamResourceConfig(teamId),
|
||||
loadAllRoadmaps(),
|
||||
]).finally(() => {
|
||||
pageProgressMessage.set('');
|
||||
setIsLoading(false);
|
||||
});
|
||||
}, [teamId]);
|
||||
|
||||
@@ -97,6 +101,7 @@ export function TeamRoadmaps() {
|
||||
return;
|
||||
}
|
||||
|
||||
toast.loading('Deleting roadmap');
|
||||
pageProgressMessage.set(`Deleting roadmap from team`);
|
||||
const { error, response } = await httpPut<TeamResourceConfig>(
|
||||
`${import.meta.env.PUBLIC_API_URL}/v1-delete-team-resource-config/${
|
||||
@@ -117,6 +122,35 @@ export function TeamRoadmaps() {
|
||||
setResourceConfigs(response);
|
||||
}
|
||||
|
||||
async function onAdd(roadmapId: string) {
|
||||
if (!teamId) {
|
||||
return;
|
||||
}
|
||||
|
||||
toast.loading('Adding roadmap');
|
||||
pageProgressMessage.set('Adding roadmap');
|
||||
setIsLoading(true);
|
||||
const { error, response } = await httpPut<TeamResourceConfig>(
|
||||
`${
|
||||
import.meta.env.PUBLIC_API_URL
|
||||
}/v1-update-team-resource-config/${teamId}`,
|
||||
{
|
||||
teamId: teamId,
|
||||
resourceId: roadmapId,
|
||||
resourceType: 'roadmap',
|
||||
removed: [],
|
||||
}
|
||||
);
|
||||
|
||||
if (error || !response) {
|
||||
toast.error(error?.message || 'Error adding roadmap');
|
||||
return;
|
||||
}
|
||||
|
||||
setResourceConfigs(response);
|
||||
toast.success('Roadmap added');
|
||||
}
|
||||
|
||||
async function onRemove(resourceId: string) {
|
||||
pageProgressMessage.set('Removing roadmap');
|
||||
|
||||
@@ -129,26 +163,69 @@ export function TeamRoadmaps() {
|
||||
return null;
|
||||
}
|
||||
|
||||
const addRoadmapModal = isAddingRoadmap && (
|
||||
<SelectRoadmapModal
|
||||
onClose={() => setIsAddingRoadmap(false)}
|
||||
teamResourceConfig={resourceConfigs}
|
||||
allRoadmaps={allRoadmaps}
|
||||
teamId={teamId}
|
||||
onRoadmapAdd={(roadmapId) => {
|
||||
onAdd(roadmapId).finally(() => {
|
||||
pageProgressMessage.set('');
|
||||
});
|
||||
}}
|
||||
onRoadmapRemove={(roadmapId) => {
|
||||
if (confirm('Are you sure you want to remove this roadmap?')) {
|
||||
onRemove(roadmapId).finally(() => {});
|
||||
}
|
||||
}}
|
||||
/>
|
||||
);
|
||||
|
||||
if (resourceConfigs.length === 0 && !isLoading) {
|
||||
return (
|
||||
<div className="flex flex-col items-center p-4 py-20">
|
||||
{addRoadmapModal}
|
||||
<img
|
||||
alt="roadmap"
|
||||
src={RoadmapIcon}
|
||||
className="mb-4 h-24 w-24 opacity-10"
|
||||
/>
|
||||
<h3 className="mb-1 text-2xl font-bold text-gray-900">No roadmaps</h3>
|
||||
<p className="text-base text-gray-500">
|
||||
{canManageCurrentTeam
|
||||
? 'Add a roadmap to start tracking your team'
|
||||
: 'Ask your team admin to add some roadmaps'}
|
||||
</p>
|
||||
|
||||
{canManageCurrentTeam && (
|
||||
<button
|
||||
className="mt-4 rounded-lg bg-black px-4 py-2 font-medium text-white hover:bg-gray-900"
|
||||
onClick={() => setIsAddingRoadmap(true)}
|
||||
>
|
||||
Add roadmap
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
{isAddingRoadmap && (
|
||||
<AddTeamRoadmap
|
||||
onMakeChanges={(roadmapId) => {
|
||||
setChangingRoadmapId(roadmapId);
|
||||
setIsAddingRoadmap(false);
|
||||
}}
|
||||
teamId={team?._id!}
|
||||
setResourceConfigs={setResourceConfigs}
|
||||
allRoadmaps={allRoadmaps}
|
||||
availableRoadmaps={allRoadmaps.filter((r) => {
|
||||
const isAlreadyAdded = resourceConfigs.find(
|
||||
(c) => c.resourceId === r.id
|
||||
);
|
||||
return !isAlreadyAdded;
|
||||
})}
|
||||
onClose={() => setIsAddingRoadmap(false)}
|
||||
/>
|
||||
)}
|
||||
{addRoadmapModal}
|
||||
<div className="mb-3 flex items-center justify-between">
|
||||
<span className={'text-gray-400'}>
|
||||
{resourceConfigs.length} roadmap(s) selected
|
||||
</span>
|
||||
{canManageCurrentTeam && (
|
||||
<button
|
||||
className="flex items-center gap-1.5 rounded-lg px-4 py-2 text-sm font-medium text-gray-500 underline hover:bg-gray-100 hover:text-gray-900"
|
||||
onClick={() => setIsAddingRoadmap(true)}
|
||||
>
|
||||
Add / Remove Roadmaps
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
<div className={'grid grid-cols-1 gap-3 sm:grid-cols-2'}>
|
||||
{changingRoadmapId && (
|
||||
<UpdateTeamResourceModal
|
||||
@@ -198,8 +275,8 @@ export function TeamRoadmaps() {
|
||||
)}
|
||||
</div>
|
||||
|
||||
{ canManageCurrentTeam && (
|
||||
<div className={'flex w-full justify-between pt-2 pb-3 px-3'}>
|
||||
{canManageCurrentTeam && (
|
||||
<div className={'flex w-full justify-between px-3 pb-3 pt-2'}>
|
||||
<button
|
||||
type="button"
|
||||
className={
|
||||
@@ -219,13 +296,7 @@ export function TeamRoadmaps() {
|
||||
className={
|
||||
'text-xs text-red-500 underline hover:text-black focus:outline-none disabled:cursor-not-allowed disabled:opacity-40 disabled:hover:text-red-500'
|
||||
}
|
||||
disabled={resourceConfigs.length === 1}
|
||||
onClick={() => setRemovingRoadmapId(resourceId)}
|
||||
title={
|
||||
resourceConfigs.length === 1
|
||||
? 'You must have at least one roadmap.'
|
||||
: 'Delete roadmap from team'
|
||||
}
|
||||
>
|
||||
Remove
|
||||
</button>
|
||||
|
||||
@@ -8,13 +8,14 @@ import MapIcon from '../icons/map.svg';
|
||||
import GroupIcon from '../icons/group.svg';
|
||||
import { useState } from 'preact/hooks';
|
||||
import { useStore } from '@nanostores/preact';
|
||||
import { $canManageCurrentTeam } from '../stores/team';
|
||||
import { $canManageCurrentTeam, $currentTeam } from '../stores/team';
|
||||
import { WarningIcon } from './ReactIcons/WarningIcon';
|
||||
|
||||
export const TeamSidebar: FunctionalComponent<{
|
||||
activePageId: string;
|
||||
}> = ({ activePageId, children }) => {
|
||||
const [menuShown, setMenuShown] = useState(false);
|
||||
const canManageCurrentTeam = useStore($canManageCurrentTeam);
|
||||
const currentTeam = useStore($currentTeam);
|
||||
|
||||
const { teamId } = useTeamId();
|
||||
|
||||
@@ -30,6 +31,7 @@ export const TeamSidebar: FunctionalComponent<{
|
||||
href: `/team/roadmaps?t=${teamId}`,
|
||||
id: 'roadmaps',
|
||||
icon: MapIcon,
|
||||
hasWarning: currentTeam?.roadmaps?.length === 0,
|
||||
},
|
||||
{
|
||||
title: 'Members',
|
||||
@@ -120,13 +122,21 @@ export const TeamSidebar: FunctionalComponent<{
|
||||
: 'border-r-transparent text-gray-500 hover:border-r-gray-300'
|
||||
}`}
|
||||
>
|
||||
<span class="flex flex-grow items-center">
|
||||
<img
|
||||
alt="menu icon"
|
||||
src={sidebarLink.icon}
|
||||
className="mr-2 h-4 w-4"
|
||||
/>
|
||||
{sidebarLink.title}
|
||||
<span class="flex flex-grow items-center justify-between">
|
||||
<span className="flex">
|
||||
<img
|
||||
alt="menu icon"
|
||||
src={sidebarLink.icon}
|
||||
className="mr-2 h-4 w-4"
|
||||
/>
|
||||
{sidebarLink.title}
|
||||
</span>
|
||||
{sidebarLink.hasWarning && (
|
||||
<span class="relative mr-1 flex items-center">
|
||||
<span class="relative rounded-full bg-red-200 p-1 text-xs" />
|
||||
<span class="absolute bottom-0 left-0 right-0 top-0 animate-ping rounded-full bg-red-400 p-1 text-xs" />
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
</a>
|
||||
</li>
|
||||
|
||||
@@ -37,7 +37,7 @@ export function Toaster(props: Props) {
|
||||
onClick={() => {
|
||||
$toastMessage.set(undefined);
|
||||
}}
|
||||
className={`fixed bottom-5 left-1/2 max-w-[300px] animate-fade-slide-up min-w-[300px] sm:min-w-[auto] z-50`}
|
||||
className={`fixed bottom-5 left-1/2 z-50 min-w-[300px] max-w-[300px] animate-fade-slide-up sm:min-w-[auto]`}
|
||||
>
|
||||
<div
|
||||
className={`flex -translate-x-1/2 transform cursor-pointer items-center gap-2 rounded-md border border-gray-200 bg-white py-3 pl-4 pr-5 text-black shadow-md hover:bg-gray-50`}
|
||||
|
||||
@@ -16,6 +16,9 @@ export async function get() {
|
||||
url: `/${roadmap.id}`,
|
||||
title: roadmap.frontmatter.briefTitle,
|
||||
group: 'Roadmaps',
|
||||
metadata: {
|
||||
tags: roadmap.frontmatter.tags,
|
||||
},
|
||||
})),
|
||||
...bestPractices.map((bestPractice) => ({
|
||||
id: bestPractice.id,
|
||||
|
||||
Reference in New Issue
Block a user