mirror of
https://github.com/ershisan99/vacancies-trends-front.git
synced 2025-12-16 20:59:25 +00:00
get keywords from api to avoid duplication, optimize some code
This commit is contained in:
@@ -1,73 +1,91 @@
|
|||||||
import { useCallback, useMemo, useState } from 'react'
|
import { useCallback, useMemo, useState } from 'react'
|
||||||
import { ALL_KEYWORDS, Keyword, KEYWORDS } from '~/services/vacancies/vacancies.constants'
|
import { KeywordsResponse, VacancyData } from '~/services/vacancies/vacancies.types'
|
||||||
import { VacancyData } from '~/services/vacancies/vacancies.types'
|
|
||||||
import { AreaChart, MultiSelect, MultiSelectItem, Select, SelectItem } from '@tremor/react'
|
import { AreaChart, MultiSelect, MultiSelectItem, Select, SelectItem } from '@tremor/react'
|
||||||
import { useSearchParams } from '@remix-run/react'
|
import { useSearchParams } from '@remix-run/react'
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
data: VacancyData
|
data?: VacancyData
|
||||||
|
keywords?: KeywordsResponse
|
||||||
}
|
}
|
||||||
|
|
||||||
const presets = {
|
export function VacanciesChart({ data, keywords }: Props) {
|
||||||
None: [],
|
|
||||||
All: ALL_KEYWORDS,
|
|
||||||
Backend: KEYWORDS.BACKEND,
|
|
||||||
Databases: KEYWORDS.DATABASES,
|
|
||||||
DevOps: KEYWORDS.DEVOPS,
|
|
||||||
Frontend: KEYWORDS.FRONTEND,
|
|
||||||
'Frontend Frameworks': KEYWORDS.FRONTEND_FRAMEWORK,
|
|
||||||
Mobile: KEYWORDS.MOBILE,
|
|
||||||
ORM: KEYWORDS.ORM,
|
|
||||||
Styles: KEYWORDS.STYLES,
|
|
||||||
'State Management': KEYWORDS.STATE_MANAGEMENT,
|
|
||||||
Testing: KEYWORDS.TESTING,
|
|
||||||
}
|
|
||||||
|
|
||||||
const presetsForSelect = Object.entries(presets).map(
|
|
||||||
([label, value]) =>
|
|
||||||
({
|
|
||||||
label,
|
|
||||||
value,
|
|
||||||
}) as const
|
|
||||||
)
|
|
||||||
|
|
||||||
export function VacanciesChart({ data }: Props) {
|
|
||||||
const [searchParams, setSearchParams] = useSearchParams()
|
const [searchParams, setSearchParams] = useSearchParams()
|
||||||
const [preset, setPreset] = useState('None' as keyof typeof presets)
|
const [preset, setPreset] = useState('None')
|
||||||
|
|
||||||
|
const presetsForSelect = useMemo(
|
||||||
|
() =>
|
||||||
|
Object.entries(keywords?.presets ?? {}).map(
|
||||||
|
([label, value]) =>
|
||||||
|
({
|
||||||
|
label,
|
||||||
|
value,
|
||||||
|
}) as const
|
||||||
|
),
|
||||||
|
[keywords?.presets]
|
||||||
|
)
|
||||||
|
|
||||||
const selectedCategories = useMemo(
|
const selectedCategories = useMemo(
|
||||||
() => searchParams.get('categories')?.split(',') || [],
|
() => searchParams.get('categories')?.split(',') || [],
|
||||||
[searchParams]
|
[searchParams]
|
||||||
)
|
)
|
||||||
|
|
||||||
const setSelectedCategories = useCallback((value: Keyword[]) => {
|
const categoriesForChart = useMemo(() => {
|
||||||
if (value.length === 0) {
|
return data?.categories.filter(category => selectedCategories.includes(category)) ?? []
|
||||||
searchParams.delete('categories')
|
}, [selectedCategories, data?.categories])
|
||||||
} else {
|
|
||||||
searchParams.set('categories', value.join(','))
|
|
||||||
}
|
|
||||||
|
|
||||||
setSearchParams(searchParams)
|
const setSelectedCategories = useCallback(
|
||||||
}, [])
|
(value: string[]) => {
|
||||||
|
if (value.length === 0) {
|
||||||
|
searchParams.delete('categories')
|
||||||
|
} else {
|
||||||
|
searchParams.set('categories', value.join(','))
|
||||||
|
}
|
||||||
|
|
||||||
const sortedCategories = useMemo(
|
setSearchParams(searchParams)
|
||||||
() => sortCategoriesByVacancies(selectedCategories, data),
|
},
|
||||||
[selectedCategories, data]
|
[searchParams, setSearchParams]
|
||||||
)
|
)
|
||||||
|
|
||||||
const filteredData = useMemo(
|
const filteredData = useMemo(
|
||||||
() =>
|
() =>
|
||||||
data.filter(row => {
|
data?.data?.filter(row => {
|
||||||
for (const category of selectedCategories) {
|
for (const category of selectedCategories) {
|
||||||
// @ts-expect-error
|
const value = row[category]
|
||||||
if (row[category] > 0) {
|
if (typeof value === 'number' && value > 0) {
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}),
|
}) ?? [],
|
||||||
[data, selectedCategories]
|
[data?.data, selectedCategories]
|
||||||
)
|
)
|
||||||
|
|
||||||
|
const handlePresetChange = useCallback(
|
||||||
|
(value: string) => {
|
||||||
|
setPreset(value)
|
||||||
|
setSelectedCategories(keywords?.presets[value] ?? [])
|
||||||
|
},
|
||||||
|
[keywords?.presets, setSelectedCategories]
|
||||||
|
)
|
||||||
|
|
||||||
|
const handleKeywordsChange = useCallback(
|
||||||
|
(value: string[]) => {
|
||||||
|
setSelectedCategories(value ?? [])
|
||||||
|
},
|
||||||
|
[setSelectedCategories]
|
||||||
|
)
|
||||||
|
|
||||||
|
const multiSelectItems = useMemo(() => {
|
||||||
|
return keywords?.allKeywords.map(category => (
|
||||||
|
<MultiSelectItem key={'category-select-' + category} value={category} />
|
||||||
|
))
|
||||||
|
}, [keywords?.allKeywords])
|
||||||
|
|
||||||
|
const presetSelectItems = useMemo(() => {
|
||||||
|
return presetsForSelect.map(category => (
|
||||||
|
<SelectItem key={'preset-select-item-' + category.label} value={category.label} />
|
||||||
|
))
|
||||||
|
}, [presetsForSelect])
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={'flex h-full flex-col gap-6 p-8'}>
|
<div className={'flex h-full flex-col gap-6 p-8'}>
|
||||||
<div className={'flex gap-4'}>
|
<div className={'flex gap-4'}>
|
||||||
@@ -80,12 +98,10 @@ export function VacanciesChart({ data }: Props) {
|
|||||||
</label>
|
</label>
|
||||||
<MultiSelect
|
<MultiSelect
|
||||||
id={'categories'}
|
id={'categories'}
|
||||||
onValueChange={value => setSelectedCategories(value)}
|
onValueChange={handleKeywordsChange}
|
||||||
value={selectedCategories}
|
value={selectedCategories}
|
||||||
>
|
>
|
||||||
{ALL_KEYWORDS.map(category => (
|
{multiSelectItems}
|
||||||
<MultiSelectItem key={'category-select-' + category} value={category} />
|
|
||||||
))}
|
|
||||||
</MultiSelect>
|
</MultiSelect>
|
||||||
</div>
|
</div>
|
||||||
<div className="max-w-xl">
|
<div className="max-w-xl">
|
||||||
@@ -99,14 +115,9 @@ export function VacanciesChart({ data }: Props) {
|
|||||||
value={preset}
|
value={preset}
|
||||||
id={'presets'}
|
id={'presets'}
|
||||||
defaultValue={'All'}
|
defaultValue={'All'}
|
||||||
onValueChange={value => {
|
onValueChange={handlePresetChange}
|
||||||
setPreset(value as keyof typeof presets)
|
|
||||||
setSelectedCategories(presets[value as keyof typeof presets] as Keyword[])
|
|
||||||
}}
|
|
||||||
>
|
>
|
||||||
{presetsForSelect.map(category => (
|
{presetSelectItems}
|
||||||
<SelectItem key={category.label} value={category.label} />
|
|
||||||
))}
|
|
||||||
</Select>
|
</Select>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -115,7 +126,7 @@ export function VacanciesChart({ data }: Props) {
|
|||||||
className="h-full"
|
className="h-full"
|
||||||
data={filteredData}
|
data={filteredData}
|
||||||
index="date"
|
index="date"
|
||||||
categories={sortedCategories}
|
categories={categoriesForChart}
|
||||||
yAxisWidth={60}
|
yAxisWidth={60}
|
||||||
startEndOnly={false}
|
startEndOnly={false}
|
||||||
intervalType="preserveStartEnd"
|
intervalType="preserveStartEnd"
|
||||||
@@ -124,23 +135,3 @@ export function VacanciesChart({ data }: Props) {
|
|||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
const sortCategoriesByVacancies = (categories: string[], data: VacancyData) => {
|
|
||||||
const entryToCompare = data.at(-1)
|
|
||||||
return categories.sort((a, b) => {
|
|
||||||
if (!entryToCompare) {
|
|
||||||
return 0
|
|
||||||
}
|
|
||||||
|
|
||||||
if (entryToCompare[a] === undefined && entryToCompare[b] === undefined) {
|
|
||||||
return 0
|
|
||||||
}
|
|
||||||
if (entryToCompare[a] > entryToCompare[b]) {
|
|
||||||
return -1
|
|
||||||
}
|
|
||||||
if (entryToCompare[a] < entryToCompare[b]) {
|
|
||||||
return 1
|
|
||||||
}
|
|
||||||
return 0
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -8,15 +8,22 @@ export const shouldRevalidate: ShouldRevalidateFunction = ({ nextParams }) => {
|
|||||||
return !nextParams
|
return !nextParams
|
||||||
}
|
}
|
||||||
export const meta: MetaFunction = () => {
|
export const meta: MetaFunction = () => {
|
||||||
return [{ title: 'New Remix App' }, { name: 'description', content: 'Welcome to Remix!' }]
|
return [
|
||||||
|
{ title: 'Vacancies trends' },
|
||||||
|
{ name: 'description', content: 'See how software vacancies change over time' },
|
||||||
|
]
|
||||||
}
|
}
|
||||||
|
|
||||||
export const loader = async () => {
|
export const loader = async () => {
|
||||||
const vacancies = await vacanciesService.getAggregateByCreatedAt()
|
const promises = [
|
||||||
return json({ vacancies })
|
vacanciesService.getAggregateByCreatedAt(),
|
||||||
|
vacanciesService.getKeywords(),
|
||||||
|
] as const
|
||||||
|
const [vacancies, keywords] = await Promise.all(promises)
|
||||||
|
return json({ vacancies, keywords })
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function Index() {
|
export default function Index() {
|
||||||
const { vacancies } = useLoaderData<typeof loader>()
|
const { vacancies, keywords } = useLoaderData<typeof loader>()
|
||||||
return <VacanciesChart data={vacancies} />
|
return <VacanciesChart data={vacancies} keywords={keywords} />
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,71 +0,0 @@
|
|||||||
export const KEYWORDS = {
|
|
||||||
BACKEND: [
|
|
||||||
'node.js',
|
|
||||||
'nestjs',
|
|
||||||
'nest.js',
|
|
||||||
'go',
|
|
||||||
'.net',
|
|
||||||
'asp.net',
|
|
||||||
'java',
|
|
||||||
'express',
|
|
||||||
'django',
|
|
||||||
'laravel',
|
|
||||||
'php',
|
|
||||||
],
|
|
||||||
LANGUAGES: [
|
|
||||||
'typescript',
|
|
||||||
'javascript',
|
|
||||||
'python',
|
|
||||||
'java',
|
|
||||||
'c#',
|
|
||||||
'c++',
|
|
||||||
'c',
|
|
||||||
'php',
|
|
||||||
'ruby',
|
|
||||||
'go',
|
|
||||||
'kotlin',
|
|
||||||
'swift',
|
|
||||||
'objective-c',
|
|
||||||
],
|
|
||||||
ORM: ['typeorm', 'prisma', 'sequelize', 'drizzle'],
|
|
||||||
get FRONTEND() {
|
|
||||||
return [
|
|
||||||
...this.FRONTEND_FRAMEWORK,
|
|
||||||
...this.STYLES,
|
|
||||||
...this.STATE_MANAGEMENT,
|
|
||||||
...this.TESTING,
|
|
||||||
'fsd',
|
|
||||||
]
|
|
||||||
},
|
|
||||||
FRONTEND_FRAMEWORK: [
|
|
||||||
'html',
|
|
||||||
'nuxt',
|
|
||||||
'react',
|
|
||||||
'remix',
|
|
||||||
'angular',
|
|
||||||
'vue',
|
|
||||||
'jquery',
|
|
||||||
'svelte',
|
|
||||||
'nextjs',
|
|
||||||
'next.js',
|
|
||||||
],
|
|
||||||
STYLES: ['css', 'sass', 'tailwind', 'styled-components', 'material ui', 'mui', 'bootstrap'],
|
|
||||||
STATE_MANAGEMENT: [
|
|
||||||
'redux',
|
|
||||||
'rtk',
|
|
||||||
'redux toolkit',
|
|
||||||
'redux toolkit query',
|
|
||||||
'effector',
|
|
||||||
'react-query',
|
|
||||||
'mobx',
|
|
||||||
],
|
|
||||||
DATABASES: ['mysql', 'postgres', 'mongodb', 'redis', 'cassandra', 'sqlite', 'firebase'],
|
|
||||||
DEVOPS: ['docker', 'kubernetes', 'jenkins', 'ansible', 'terraform'],
|
|
||||||
TESTING: ['jest', 'mocha', 'cypress', 'selenium', 'playwright', 'jasmine', 'puppeteer', 'vitest'],
|
|
||||||
MOBILE: ['react native', 'flutter', 'swift', 'kotlin', 'xamarin', 'objective-c'],
|
|
||||||
TOOLS: ['webpack', 'vite', 'graphql', 'rest', 'storybook'],
|
|
||||||
} as const
|
|
||||||
|
|
||||||
export const ALL_KEYWORDS = [...new Set(Object.values(KEYWORDS).flat().sort())]
|
|
||||||
|
|
||||||
export type Keyword = (typeof ALL_KEYWORDS)[number]
|
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
import { Vacancies, VacancyData } from '~/services/vacancies/vacancies.types'
|
import { KeywordsResponse, Vacancies, VacancyData } from '~/services/vacancies/vacancies.types'
|
||||||
|
|
||||||
export class VacanciesService {
|
export class VacanciesService {
|
||||||
baseUrl = 'https://vacancies-trends-api.andrii.es'
|
baseUrl = process.env.VACANCIES_API_URL ?? 'https://vacancies-trends-api.andrii.es'
|
||||||
|
|
||||||
async getAll(): Promise<Vacancies> {
|
async getAll(): Promise<Vacancies> {
|
||||||
return await fetch(`${this.baseUrl}/vacancies`).then(res => res.json())
|
return await fetch(`${this.baseUrl}/vacancies`).then(res => res.json())
|
||||||
@@ -13,13 +13,21 @@ export class VacanciesService {
|
|||||||
.then(this.formatDateOnData)
|
.then(this.formatDateOnData)
|
||||||
}
|
}
|
||||||
|
|
||||||
formatDateOnData(data: VacancyData): VacancyData {
|
async getKeywords(): Promise<KeywordsResponse> {
|
||||||
return data.map(item => {
|
return await fetch(`${this.baseUrl}/vacancies/keywords`).then(res => res.json())
|
||||||
|
}
|
||||||
|
|
||||||
|
private formatDateOnData(data: VacancyData): VacancyData {
|
||||||
|
const mapped = data.data.map(item => {
|
||||||
return {
|
return {
|
||||||
...item,
|
...item,
|
||||||
date: new Date(item.date).toLocaleDateString('ru'),
|
date: new Date(item.date).toLocaleDateString('ru'),
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
return {
|
||||||
|
...data,
|
||||||
|
data: mapped,
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -8,4 +8,13 @@ export interface VacancyDataEntry {
|
|||||||
vacancies: number
|
vacancies: number
|
||||||
}
|
}
|
||||||
|
|
||||||
export type VacancyData = Array<{ date: string; [key: string]: string | number }>
|
export type VacancyData = {
|
||||||
|
categories: string[]
|
||||||
|
data: Array<{ date: string; [key: string]: string | number }>
|
||||||
|
}
|
||||||
|
|
||||||
|
export type KeywordsResponse = {
|
||||||
|
allKeywords: string[]
|
||||||
|
keywords: Record<string, string[]>
|
||||||
|
presets: Record<string, string[]>
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user