Skip to content

Commit

Permalink
템플릿 생성 및 수정시 파일명 랜덤 생성 및 중복호출 막기, 공개 범위 설정 radio UI로 변경 (#939)
Browse files Browse the repository at this point in the history
  • Loading branch information
Hain-tain authored Dec 5, 2024
1 parent 0a75756 commit 0f2d554
Show file tree
Hide file tree
Showing 18 changed files with 310 additions and 158 deletions.
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}>
<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()}`);
};
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>;
};

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

0 comments on commit 0f2d554

Please sign in to comment.