From 7bc4a998fee89ad6aa4e3555b74a56b6b263ad0d Mon Sep 17 00:00:00 2001 From: Alexander KIRILOV Date: Thu, 27 Oct 2022 15:43:15 +0200 Subject: [PATCH] feat: additional work sections --- .gitignore | 5 +- .../build/LeftSidebar/LeftSidebar.tsx | 56 ++++++++++++++++--- .../build/LeftSidebar/sections/Section.tsx | 46 +++++++++++++-- client/config/sections.tsx | 49 ++++++++++++---- client/modals/auth/LoginModal.tsx | 3 +- client/modals/builder/sections/WorkModal.tsx | 10 ++-- client/public/locales/en/builder.json | 3 +- client/store/modal/modalSlice.ts | 1 + client/store/resume/resumeSlice.ts | 16 +++++- client/templates/Pikachu/widgets/Timeline.tsx | 0 client/templates/sectionMap.tsx | 13 ++++- schema/src/section.ts | 18 +++++- server/src/resume/data/defaultState.ts | 2 +- 13 files changed, 184 insertions(+), 38 deletions(-) create mode 100644 client/templates/Pikachu/widgets/Timeline.tsx diff --git a/.gitignore b/.gitignore index b76b46fe7..fb8e15073 100644 --- a/.gitignore +++ b/.gitignore @@ -10,4 +10,7 @@ node_modules .DS_Store # Turbo -.turbo \ No newline at end of file +.turbo + +# Intellij +.idea diff --git a/client/components/build/LeftSidebar/LeftSidebar.tsx b/client/components/build/LeftSidebar/LeftSidebar.tsx index ea53eabc1..e24613849 100644 --- a/client/components/build/LeftSidebar/LeftSidebar.tsx +++ b/client/components/build/LeftSidebar/LeftSidebar.tsx @@ -1,14 +1,15 @@ import { Add, Star } from '@mui/icons-material'; import { Button, Divider, IconButton, SwipeableDrawer, Tooltip, useMediaQuery, useTheme } from '@mui/material'; import { Section as SectionRecord } from '@reactive-resume/schema'; +import cloneDeep from 'lodash/cloneDeep'; import get from 'lodash/get'; import Link from 'next/link'; import { useTranslation } from 'next-i18next'; -import { useMemo } from 'react'; +import React, { ReactComponentElement, useMemo } from 'react'; import { validate } from 'uuid'; import Logo from '@/components/shared/Logo'; -import { getCustomSections, left } from '@/config/sections'; +import { getCustomSections, getSectionsByType, left } from '@/config/sections'; import { setSidebarState } from '@/store/build/buildSlice'; import { useAppDispatch, useAppSelector } from '@/store/hooks'; import { addSection } from '@/store/resume/resumeSlice'; @@ -52,7 +53,49 @@ const LeftSidebar = () => { items: [], }; - dispatch(addSection({ value: newSection })); + dispatch(addSection({ value: newSection, type: 'custom' })); + }; + + const sectionsList = () => { + const sectionsComponents: Array> = []; + + for (const item of left) { + const id = (item as any).id; + const component = (item as any).component; + const type = component.props.type || 'basic'; + const addMore = !!component.props.addMore; + + sectionsComponents.push( +
+ {component} +
+ ); + + if (addMore) { + const additionalSections = getSectionsByType(sections, type); + const elements = []; + for (const element of additionalSections) { + const newId = element.id; + + const props = cloneDeep(component.props); + props.path = 'sections.' + newId; + props.name = element.name; + props.isDeletable = true; + props.addMore = false; + props.isDuplicated = true; + const newComponent = React.cloneElement(component, props); + + elements.push( +
+ {newComponent} +
+ ); + } + sectionsComponents.push(...elements); + } + } + + return sectionsComponents; }; return ( @@ -100,12 +143,7 @@ const LeftSidebar = () => {
- {left.map(({ id, component }) => ( -
- {component} -
- ))} - + {sectionsList()} {customSections.map(({ id }) => (
diff --git a/client/components/build/LeftSidebar/sections/Section.tsx b/client/components/build/LeftSidebar/sections/Section.tsx index cd4015d63..99f81f1ad 100644 --- a/client/components/build/LeftSidebar/sections/Section.tsx +++ b/client/components/build/LeftSidebar/sections/Section.tsx @@ -1,6 +1,6 @@ import { Add } from '@mui/icons-material'; import { Button } from '@mui/material'; -import { ListItem } from '@reactive-resume/schema'; +import { ListItem, Section as SectionRecord, SectionType } from '@reactive-resume/schema'; import clsx from 'clsx'; import get from 'lodash/get'; import { useTranslation } from 'next-i18next'; @@ -10,28 +10,34 @@ import Heading from '@/components/shared/Heading'; import List from '@/components/shared/List'; import { useAppDispatch, useAppSelector } from '@/store/hooks'; import { ModalName, setModalState } from '@/store/modal/modalSlice'; -import { duplicateItem } from '@/store/resume/resumeSlice'; +import { duplicateItem, duplicateSection } from '@/store/resume/resumeSlice'; import SectionSettings from './SectionSettings'; type Props = { path: `sections.${string}`; + type?: SectionType; name?: string; titleKey?: string; subtitleKey?: string; isEditable?: boolean; isHideable?: boolean; isDeletable?: boolean; + addMore?: boolean; + isDuplicated?: boolean; }; const Section: React.FC = ({ path, name = 'Section Name', + type = 'basic', titleKey = 'title', subtitleKey = 'subtitle', isEditable = false, isHideable = false, isDeletable = false, + addMore = false, + isDuplicated = false, }) => { const { t } = useTranslation(); @@ -42,21 +48,43 @@ const Section: React.FC = ({ const handleAdd = () => { const id = path.split('.')[1]; - const modal: ModalName = validate(id) ? 'builder.sections.custom' : `builder.${path}`; + let modal: ModalName = validate(id) ? 'builder.sections.custom' : `builder.${path}`; + if (type) { + modal = `builder.sections.${type}`; + } dispatch(setModalState({ modal, state: { open: true, payload: { path } } })); }; const handleEdit = (item: ListItem) => { const id = path.split('.')[1]; - const modal: ModalName = validate(id) ? 'builder.sections.custom' : `builder.${path}`; + let modal: ModalName = validate(id) ? 'builder.sections.custom' : `builder.${path}`; + const payload = validate(id) ? { path, item } : { item }; + if (isDuplicated) { + modal = `builder.sections.${type}`; + payload.path = path; + } + dispatch(setModalState({ modal, state: { open: true, payload } })); }; const handleDuplicate = (item: ListItem) => dispatch(duplicateItem({ path: `${path}.items`, value: item })); + const handleDuplicateSection = () => { + const newSection: SectionRecord = { + name: `${heading}`, + type: type, + visible: true, + columns: 2, + items: [], + isDuplicated: true + }; + + dispatch(duplicateSection({ value: newSection, type })); + }; + return ( <> @@ -77,6 +105,16 @@ const Section: React.FC = ({ {t('builder.common.actions.add', { token: heading })} + + {addMore ? ( +
+ +
+ ) : ( + <> + )} ); }; diff --git a/client/config/sections.tsx b/client/config/sections.tsx index 48baf85b4..3c295d65b 100644 --- a/client/config/sections.tsx +++ b/client/config/sections.tsx @@ -23,7 +23,7 @@ import { VolunteerActivism, Work, } from '@mui/icons-material'; -import { Section as SectionRecord } from '@reactive-resume/schema'; +import { Section as SectionRecord, SectionType } from '@reactive-resume/schema'; import isEmpty from 'lodash/isEmpty'; import Basics from '@/components/build/LeftSidebar/sections/Basics'; @@ -60,59 +60,69 @@ export const left: SidebarSection[] = [ { id: 'work', icon: , - component:
, + component: ( +
+ ), }, { id: 'education', icon: , - component:
, + component:
, }, { id: 'awards', icon: , - component:
, + component:
, }, { id: 'certifications', icon: , - component:
, + component:
, }, { id: 'publications', icon: , - component:
, + component:
, }, { id: 'skills', icon: , - component:
, + component:
, }, { id: 'languages', icon: , - component:
, + component:
, }, { id: 'interests', icon: , - component:
, + component:
, }, { id: 'volunteer', icon: , component: ( -
+
), }, { id: 'projects', icon: , - component:
, + component:
, }, { id: 'references', icon: , - component:
, + component:
, }, ]; @@ -164,6 +174,21 @@ export const right: SidebarSection[] = [ }, ]; +export const getSectionsByType = ( + sections: Record, + type: SectionType +): Array> => { + if (isEmpty(sections)) return []; + + return Object.entries(sections).reduce((acc, [id, section]) => { + if (section.type.startsWith(type) && section.isDuplicated) { + return [...acc, { ...section, id }]; + } + + return acc; + }, [] as Array>); +}; + export const getCustomSections = (sections: Record): Array> => { if (isEmpty(sections)) return []; diff --git a/client/modals/auth/LoginModal.tsx b/client/modals/auth/LoginModal.tsx index 4e49430ae..613706604 100644 --- a/client/modals/auth/LoginModal.tsx +++ b/client/modals/auth/LoginModal.tsx @@ -169,7 +169,8 @@ const LoginModal: React.FC = () => {

- In case you have forgotten your password, you can recover your account here. + In case you have forgotten your password, you can{' '} + recover your account here.

diff --git a/client/modals/builder/sections/WorkModal.tsx b/client/modals/builder/sections/WorkModal.tsx index 22cb940f5..f070c2565 100644 --- a/client/modals/builder/sections/WorkModal.tsx +++ b/client/modals/builder/sections/WorkModal.tsx @@ -2,7 +2,7 @@ import { joiResolver } from '@hookform/resolvers/joi'; import { Add, DriveFileRenameOutline } from '@mui/icons-material'; import { Button, TextField } from '@mui/material'; import { DatePicker } from '@mui/x-date-pickers'; -import { SectionPath, WorkExperience } from '@reactive-resume/schema'; +import { WorkExperience } from '@reactive-resume/schema'; import dayjs from 'dayjs'; import Joi from 'joi'; import get from 'lodash/get'; @@ -20,8 +20,6 @@ import { addItem, editItem } from '@/store/resume/resumeSlice'; type FormData = WorkExperience; -const path: SectionPath = 'sections.work'; - const defaultState: FormData = { name: '', position: '', @@ -51,9 +49,11 @@ const WorkModal: React.FC = () => { const dispatch = useAppDispatch(); const heading = useAppSelector((state) => get(state.resume.present, `${path}.name`)); - const { open: isOpen, payload } = useAppSelector((state) => state.modal[`builder.${path}`]); + const { open: isOpen, payload } = useAppSelector((state) => state.modal['builder.sections.work']); + const path: string = get(payload, 'path', 'sections.work'); const item: FormData = get(payload, 'item', null); + const isEditMode = useMemo(() => !!item, [item]); const addText = useMemo(() => t('builder.common.actions.add', { token: heading }), [t, heading]); @@ -77,7 +77,7 @@ const WorkModal: React.FC = () => { const handleClose = () => { dispatch( setModalState({ - modal: `builder.${path}`, + modal: 'builder.sections.work', state: { open: false }, }) ); diff --git a/client/public/locales/en/builder.json b/client/public/locales/en/builder.json index 18d7b7fae..d2ea6c532 100644 --- a/client/public/locales/en/builder.json +++ b/client/public/locales/en/builder.json @@ -3,7 +3,8 @@ "actions": { "add": "Add New {{token}}", "delete": "Delete {{token}}", - "edit": "Edit {{token}}" + "edit": "Edit {{token}}", + "duplicate": "Duplicate Section" }, "columns": { "heading": "Columns", diff --git a/client/store/modal/modalSlice.ts b/client/store/modal/modalSlice.ts index 233e09024..f8f0e5445 100644 --- a/client/store/modal/modalSlice.ts +++ b/client/store/modal/modalSlice.ts @@ -9,6 +9,7 @@ export type ModalName = | 'dashboard.import-external' | 'dashboard.rename-resume' | 'builder.sections.profile' + | 'builder.sections.work' | `builder.sections.${string}`; export type ModalState = { diff --git a/client/store/resume/resumeSlice.ts b/client/store/resume/resumeSlice.ts index 6cb3b78d5..b3724a61b 100644 --- a/client/store/resume/resumeSlice.ts +++ b/client/store/resume/resumeSlice.ts @@ -1,4 +1,4 @@ -import { ListItem, Profile, Resume, Section } from '@reactive-resume/schema'; +import { ListItem, Profile, Resume, Section, SectionType } from '@reactive-resume/schema'; import { createSlice, PayloadAction } from '@reduxjs/toolkit'; import cloneDeep from 'lodash/cloneDeep'; import get from 'lodash/get'; @@ -7,6 +7,8 @@ import pick from 'lodash/pick'; import set from 'lodash/set'; import { v4 as uuidv4 } from 'uuid'; +import { getSectionsByType } from '@/config/sections'; + type SetResumeStatePayload = { path: string; value: unknown }; type AddItemPayload = { path: string; value: ListItem }; @@ -17,7 +19,7 @@ type DuplicateItemPayload = { path: string; value: ListItem }; type DeleteItemPayload = { path: string; value: ListItem }; -type AddSectionPayload = { value: Section }; +type AddSectionPayload = { value: Section; type: SectionType }; type DeleteSectionPayload = { path: string }; @@ -80,6 +82,15 @@ export const resumeSlice = createSlice({ state.sections[id] = value; state.metadata.layout[0][0].push(id); }, + duplicateSection: (state: Resume, action: PayloadAction) => { + const { value, type } = action.payload; + + const id = getSectionsByType(state.sections, type).length + 1; + value.name = value.name + '-' + id; + + state.sections[`${type}-${id}`] = value; + state.metadata.layout[0][0].push(`${type}-${id}`); + }, deleteSection: (state: Resume, action: PayloadAction) => { const { path } = action.payload; const id = path.split('.')[1]; @@ -119,6 +130,7 @@ export const { duplicateItem, deleteItem, addSection, + duplicateSection, deleteSection, addPage, deletePage, diff --git a/client/templates/Pikachu/widgets/Timeline.tsx b/client/templates/Pikachu/widgets/Timeline.tsx new file mode 100644 index 000000000..e69de29bb diff --git a/client/templates/sectionMap.tsx b/client/templates/sectionMap.tsx index edd6ec8f7..3793945e1 100644 --- a/client/templates/sectionMap.tsx +++ b/client/templates/sectionMap.tsx @@ -1,3 +1,4 @@ +import { find } from 'lodash'; import get from 'lodash/get'; import React from 'react'; import { validate } from 'uuid'; @@ -44,11 +45,21 @@ const sectionMap = (Section: React.FC): Record): JSX.Element => { + // Check if section id is a custom section (an uuid) if (validate(id)) { return
; } - return get(sectionMap(Section), id); + // Check if section id is a predefined seciton in config + const predefinedSection = get(sectionMap(Section), id); + + if(predefinedSection) { + return predefinedSection; + } + + // Other ways section should be a cloned section + const section = find(sectionMap(Section), (element, key) => id.includes(key)); + return React.cloneElement(section!, { path: `sections.${id}` }); }; export default sectionMap; diff --git a/schema/src/section.ts b/schema/src/section.ts index 26e0f8cf0..e3d8f4a97 100644 --- a/schema/src/section.ts +++ b/schema/src/section.ts @@ -125,7 +125,22 @@ export type ListItem = | WorkExperience | Custom; -export type SectionType = 'basic' | 'custom'; +export type SectionType = + | 'basic' + | 'location' + | 'profiles' + | 'education' + | 'awards' + | 'certifications' + | 'publications' + | 'skills' + | 'languages' + | 'interests' + | 'volunteer' + | 'projects' + | 'references' + | 'custom' + | 'work'; export type SectionPath = `sections.${string}`; @@ -136,4 +151,5 @@ export type Section = { columns: number; visible: boolean; items: ListItem[]; + isDuplicated: boolean; }; diff --git a/server/src/resume/data/defaultState.ts b/server/src/resume/data/defaultState.ts index 935e2b8fc..e6b0dc3f3 100644 --- a/server/src/resume/data/defaultState.ts +++ b/server/src/resume/data/defaultState.ts @@ -38,7 +38,7 @@ const defaultState: Partial = { work: { id: 'work', name: 'Work Experience', - type: 'basic', + type: 'work', columns: 2, visible: true, items: [],