Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

템플릿 생성 및 수정시 파일명 랜덤 생성 및 중복호출 막기, 공개 범위 설정 radio UI로 변경 #939

Merged
merged 10 commits into from
Dec 5, 2024
Merged
20 changes: 12 additions & 8 deletions frontend/.storybook/preview.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,8 @@ import { MemoryRouter } from 'react-router-dom';
import GlobalStyles from '../src/style/GlobalStyles';
import { AuthProvider, ToastProvider } from '../src/contexts';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { ThemeProvider } from '@emotion/react';
import { theme } from '../src/style/theme';

const queryClient = new QueryClient();

Expand All @@ -20,14 +22,16 @@ const preview: Preview = {
decorators: [
(Story) => (
<QueryClientProvider client={queryClient}>
<AuthProvider>
<ToastProvider>
<GlobalStyles />
<MemoryRouter>
<Story />
</MemoryRouter>
</ToastProvider>
</AuthProvider>
<ThemeProvider theme={theme}>
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

이번에 Radio 컴포넌트에서 ThemeProvider theme을 사용하여서 추가하게 되었습니다

<AuthProvider>
<ToastProvider>
<GlobalStyles />
<MemoryRouter>
<Story />
</MemoryRouter>
</ToastProvider>
</AuthProvider>
</ThemeProvider>
</QueryClientProvider>
),
],
Expand Down
4 changes: 2 additions & 2 deletions frontend/src/api/authentication.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,10 +12,10 @@ export const postLogin = async (loginInfo: LoginRequest) => {

export const postLogout = async () => await apiClient.post(END_POINTS.LOGOUT, {});

export const getLoginState = async () => apiClient.get(END_POINTS.LOGIN_CHECK);
export const getLoginState = async () => await apiClient.get(END_POINTS.LOGIN_CHECK);

export const checkName = async (name: string) => {
const params = new URLSearchParams({ name });

await apiClient.get(`${END_POINTS.CHECK_NAME}?${params.toString()}`);
return await apiClient.get(`${END_POINTS.CHECK_NAME}?${params.toString()}`);
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

image

  • queryFn 으로 주는 checkName 가 아무것도 반환하지 않아 위와 같은 에러가 발생하였습니다.
  • React Query에서 queryFn 함수는 반드시 값을 반환해야 한다고 하여 위와 같이 반환하도록 변경하였습니다.

};
22 changes: 22 additions & 0 deletions frontend/src/components/Radio/Radio.stories.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
import type { Meta, StoryObj } from '@storybook/react';
import { useState } from 'react';

import Radio from './Radio';

const meta: Meta<typeof Radio> = {
title: 'Radio',
component: Radio,
};

export default meta;

type Story = StoryObj<typeof Radio>;

export const Default: Story = {
render: () => {
const options = { 코: '코', 드: '드', 잽: '잽' };
const [currentValue, setCurrentValue] = useState('코');

return <Radio options={options} currentValue={currentValue} handleCurrentValue={setCurrentValue} />;
},
};
25 changes: 25 additions & 0 deletions frontend/src/components/Radio/Radio.style.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
import styled from '@emotion/styled';

export const RadioContainer = styled.div`
display: flex;
gap: 2rem;
`;

export const RadioOption = styled.button`
cursor: pointer;

display: flex;
gap: 1rem;
align-items: center;
justify-content: center;
`;

export const RadioCircle = styled.div<{ isSelected: boolean }>`
width: 1rem;
height: 1rem;

background-color: ${({ theme, isSelected }) =>
isSelected ? theme.color.light.primary_500 : theme.color.light.secondary_300};
border: ${({ theme }) => `0.18rem solid ${theme.color.light.secondary_300}`};
border-radius: 100%;
`;
23 changes: 23 additions & 0 deletions frontend/src/components/Radio/Radio.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
import { Text } from '@/components';
import { theme } from '@/style/theme';

import * as S from './Radio.style';

interface Props<T extends string> {
options: Record<T, T | string | number>;
currentValue: T;
handleCurrentValue: (value: T) => void;
}

const Radio = <T extends string>({ options, currentValue, handleCurrentValue }: Props<T>) => (
<S.RadioContainer>
{Object.keys(options).map((optionKey) => (
<S.RadioOption key={optionKey} onClick={() => handleCurrentValue(optionKey as T)}>
<S.RadioCircle isSelected={currentValue === optionKey} />
<Text.Medium color={theme.color.light.secondary_800}>{options[optionKey as T]}</Text.Medium>
</S.RadioOption>
))}
</S.RadioContainer>
);

export default Radio;
1 change: 1 addition & 0 deletions frontend/src/components/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@ export { default as ContactUs } from './ContactUs/ContactUs';
export { default as Toggle } from './Toggle/Toggle';
export { default as ScrollTopButton } from './ScrollTopButton/ScrollTopButton';
export { default as AuthorInfo } from './AuthorInfo/AuthorInfo';
export { default as Radio } from './Radio/Radio';

// Skeleton UI
export { default as LoadingBall } from './LoadingBall/LoadingBall';
Expand Down
1 change: 1 addition & 0 deletions frontend/src/hooks/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,3 +14,4 @@ export { useSelectList } from './useSelectList';
export { useCustomNavigate } from './useCustomNavigate';
export { useQueryParams } from './useQueryParams';
export { useToast } from './useToast';
export { usePreventDuplicateMutation } from './usePreventDuplicateMutation';
38 changes: 38 additions & 0 deletions frontend/src/hooks/usePreventDuplicateMutation.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
import { useMutation, UseMutationOptions, UseMutationResult, MutationFunction } from '@tanstack/react-query';
import { useRef } from 'react';

type RequiredMutationFn<TData = unknown, TError = Error, TVariables = void, TContext = unknown> = UseMutationOptions<
TData,
TError,
TVariables,
TContext
> & {
mutationFn: MutationFunction<TData, TVariables>;
};
Comment on lines +9 to +11
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

mutationFn 을 반드시 전달하도록 타입으로 강제하기위해 이렇게 설정해주었습니다!


export const usePreventDuplicateMutation = <TData = unknown, TError = Error, TVariables = void, TContext = unknown>(
options: RequiredMutationFn<TData, TError, TVariables, TContext>,
): UseMutationResult<TData, TError, TVariables, TContext> => {
const isMutatingRef = useRef(false);

const originalMutationFn = options.mutationFn;

const preventDuplicateMutationFn: MutationFunction<TData, TVariables> = async (variables: TVariables) => {
if (isMutatingRef.current) {
return undefined as TData;
}

isMutatingRef.current = true;

try {
return await originalMutationFn(variables);
} finally {
isMutatingRef.current = false;
}
};

return useMutation({
...options,
mutationFn: preventDuplicateMutationFn,
});
};
6 changes: 0 additions & 6 deletions frontend/src/pages/TemplateEditPage/TemplateEditPage.style.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,12 +23,6 @@ export const MainContainer = styled.div`
margin-top: 3rem;
`;

export const CategoryAndVisibilityContainer = styled.div`
display: flex;
align-items: flex-end;
justify-content: space-between;
`;

export const CancelButton = styled(Button)`
background-color: white;
`;
Expand Down
134 changes: 74 additions & 60 deletions frontend/src/pages/TemplateEditPage/TemplateEditPage.tsx
Original file line number Diff line number Diff line change
@@ -1,14 +1,27 @@
import { useState } from 'react';

import { PlusIcon, PrivateIcon, PublicIcon } from '@/assets/images';
import { Button, Input, SelectList, SourceCodeEditor, Text, CategoryDropdown, TagInput, Toggle } from '@/components';
import { PlusIcon } from '@/assets/images';
import {
Button,
Input,
SelectList,
SourceCodeEditor,
Text,
CategoryDropdown,
TagInput,
LoadingBall,
Textarea,
Radio,
} from '@/components';
import { useInput, useSelectList, useToast } from '@/hooks';
import { useAuth } from '@/hooks/authentication';
import { useCategory } from '@/hooks/category';
import { useTag, useSourceCode } from '@/hooks/template';
import { useTemplateEditMutation } from '@/queries/templates';
import { useTrackPageViewed } from '@/service/amplitude';
import { TEMPLATE_VISIBILITY } from '@/service/constants';
import { VISIBILITY_OPTIONS } from '@/service/constants';
import { generateUniqueFilename, isFilenameEmpty } from '@/service/generateUniqueFilename';
import { validateTemplate } from '@/service/validates';
import { ICON_SIZE } from '@/style/styleConstants';
import { theme } from '@/style/theme';
import type { Template, TemplateEditRequest } from '@/types';
Expand Down Expand Up @@ -49,44 +62,20 @@ const TemplateEditPage = ({ template, toggleEditButton }: Props) => {

const { currentOption: currentFile, linkedElementRefs: sourceCodeRefs, handleSelectOption } = useSelectList();

const { mutateAsync: updateTemplate, error } = useTemplateEditMutation(template.id);
const { mutateAsync: updateTemplate, isPending, error } = useTemplateEditMutation(template.id);

const { failAlert } = useToast();

const validateTemplate = () => {
if (!title) {
return '제목을 입력해주세요';
}

if (sourceCodes.filter(({ filename }) => !filename || filename.trim() === '').length) {
return '파일명을 입력해주세요';
}

if (sourceCodes.filter(({ content }) => !content || content.trim() === '').length) {
return '소스코드 내용을 입력해주세요';
}

return '';
};

const handleCancelButton = () => {
toggleEditButton();
};

const handleSaveButtonClick = async () => {
if (validateTemplate()) {
failAlert(validateTemplate());

if (!canSaveTemplate()) {
return;
}

const orderedSourceCodes = sourceCodes.map((sourceCode, index) => ({
...sourceCode,
ordinal: index + 1,
}));

const createSourceCodes = orderedSourceCodes.filter((sourceCode) => !sourceCode.id);
const updateSourceCodes = orderedSourceCodes.filter((sourceCode) => sourceCode.id);
const { createSourceCodes, updateSourceCodes } = generateProcessedSourceCodes();

const templateUpdate: TemplateEditRequest = {
id: template.id,
Expand All @@ -100,45 +89,59 @@ const TemplateEditPage = ({ template, toggleEditButton }: Props) => {
visibility,
};

try {
await updateTemplate(templateUpdate);
toggleEditButton();
} catch (error) {
console.error('Failed to update template:', error);
await updateTemplate(templateUpdate);
toggleEditButton();
};

const canSaveTemplate = () => {
const errorMessage = validateTemplate(title, sourceCodes);

if (errorMessage) {
failAlert(errorMessage);

return false;
}

return true;
};

const generateProcessedSourceCodes = () => {
const processSourceCodes = sourceCodes.map((sourceCode, index) => {
const { filename } = sourceCode;

return {
...sourceCode,
ordinal: index + 1,
filename: isFilenameEmpty(filename) ? generateUniqueFilename() : filename,
};
});

const createSourceCodes = processSourceCodes.filter((sourceCode) => !sourceCode.id);
const updateSourceCodes = processSourceCodes.filter((sourceCode) => sourceCode.id);

return { createSourceCodes, updateSourceCodes };
};

return (
<S.TemplateEditContainer>
<S.MainContainer>
<S.CategoryAndVisibilityContainer>
<CategoryDropdown {...categoryProps} />
<Toggle
showOptions={false}
options={[...TEMPLATE_VISIBILITY]}
optionAdornments={[
<PrivateIcon key={TEMPLATE_VISIBILITY[1]} width={ICON_SIZE.MEDIUM_SMALL} />,
<PublicIcon key={TEMPLATE_VISIBILITY[0]} width={ICON_SIZE.MEDIUM_SMALL} />,
]}
optionSliderColor={[undefined, theme.color.light.triadic_primary_800]}
selectedOption={visibility}
switchOption={setVisibility}
/>
</S.CategoryAndVisibilityContainer>
<CategoryDropdown {...categoryProps} />

<S.UnderlineInputWrapper>
<Input size='xlarge' variant='text'>
<Input.TextField placeholder='제목을 입력해주세요' value={title} onChange={handleTitleChange} />
</Input>
</S.UnderlineInputWrapper>

<Input size='large' variant='text'>
<Input.TextField
<Textarea size='medium' variant='text'>
<Textarea.TextField
placeholder='이 템플릿을 언제 다시 쓸 것 같나요?'
minRows={1}
maxRows={5}
value={description}
onChange={handleDescriptionChange}
/>
</Input>
</Textarea>

{sourceCodes.map((sourceCode, index) => (
<SourceCodeEditor
Expand Down Expand Up @@ -167,14 +170,25 @@ const TemplateEditPage = ({ template, toggleEditButton }: Props) => {

<TagInput {...tagProps} />

<S.ButtonGroup>
<S.CancelButton size='medium' variant='outlined' onClick={handleCancelButton}>
취소
</S.CancelButton>
<Button size='medium' variant='contained' onClick={handleSaveButtonClick} disabled={sourceCodes.length === 0}>
저장
</Button>
</S.ButtonGroup>
<Radio options={VISIBILITY_OPTIONS} currentValue={visibility} handleCurrentValue={setVisibility} />

{isPending ? (
<LoadingBall />
) : (
<S.ButtonGroup>
<S.CancelButton size='medium' variant='outlined' onClick={handleCancelButton}>
취소
</S.CancelButton>
<Button
size='medium'
variant='contained'
onClick={handleSaveButtonClick}
disabled={sourceCodes.length === 0}
>
저장
</Button>
</S.ButtonGroup>
)}

{error && <Text.Medium color={theme.color.light.analogous_primary_400}>Error: {error.message}</Text.Medium>}
</S.MainContainer>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -23,12 +23,6 @@ export const MainContainer = styled.div`
margin-top: 3rem;
`;

export const CategoryAndVisibilityContainer = styled.div`
display: flex;
align-items: flex-end;
justify-content: space-between;
`;

export const CancelButton = styled(Button)`
background-color: white;
`;
Expand Down
Loading
Loading