diff --git a/pages/[lang]/name.tsx b/pages/[lang]/name.tsx new file mode 100644 index 00000000..8902c00e --- /dev/null +++ b/pages/[lang]/name.tsx @@ -0,0 +1,4 @@ +import {UnitNameRefPage} from '../../src/components/pages/gameData/nameRef/main'; + + +export default UnitNameRefPage; diff --git a/src/api-def b/src/api-def index b3d6682f..6c568109 160000 --- a/src/api-def +++ b/src/api-def @@ -1 +1 @@ -Subproject commit b3d6682f5b3389d108dcd90c103cd0a418ac8429 +Subproject commit 6c568109ee94f33bb4a783fbbe2ae97d66d6ef5f diff --git a/src/components/pages/gameData/nameRef/entry.tsx b/src/components/pages/gameData/nameRef/entry.tsx new file mode 100644 index 00000000..999fb8ed --- /dev/null +++ b/src/components/pages/gameData/nameRef/entry.tsx @@ -0,0 +1,81 @@ +import React from 'react'; + +import Col from 'react-bootstrap/Col'; +import Form from 'react-bootstrap/Form'; +import Row from 'react-bootstrap/Row'; + +import {UnitNameRefEntry as UnitNameRefEntryApi} from '../../../../api-def/api'; +import {useI18n} from '../../../../i18n/hook'; +import {useUnitInfo} from '../../../../utils/services/resources/unitInfo/hooks'; +import {UnitIcon} from '../../../elements/gameData/unitIcon'; +import {ArrayDataFormOnChangedHandler} from '../../../elements/posts/shared/form/array'; + + +type UnitNameRefEntryProps = { + entry: UnitNameRefEntryApi, + onChanged: ArrayDataFormOnChangedHandler, +} + +export const UnitNameRefEntry = ({entry, onChanged}: UnitNameRefEntryProps) => { + const {t, lang} = useI18n(); + const {unitInfoMap} = useUnitInfo(); + + const unitInfo = unitInfoMap.get(entry.unitId); + + // This is the height of `form-control` + const unitIconHeight = 'calc(1.5em + 0.75rem + 2px)'; + + const isValid = !!unitInfo; + + return ( +
+ + + {t((t) => t.game.nameRef.unitId)} + { + if (Number(e.target.value) || !e.target.value) { + onChanged('unitId')(+e.target.value); + } + }} + value={entry.unitId} + /> + + + {t((t) => t.game.nameRef.actualName)} + + { + unitInfo ? + <> + + + + + {unitInfo.name[lang]} + + : + + + {t((t) => t.game.nameRef.error.invalidUnitId)} + + + } + + + + {t((t) => t.game.nameRef.desiredName)} + onChanged('name')(e.target.value)} + isInvalid={!entry.name} + disabled={!isValid} + value={entry.name} + /> + + +
+ ); +}; diff --git a/src/components/pages/gameData/nameRef/main.test.tsx b/src/components/pages/gameData/nameRef/main.test.tsx new file mode 100644 index 00000000..ca2ec4d6 --- /dev/null +++ b/src/components/pages/gameData/nameRef/main.test.tsx @@ -0,0 +1,239 @@ +import React from 'react'; + +import {screen, waitFor} from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; + +import {renderReact} from '../../../../../test/render/main'; +import {typeInput} from '../../../../../test/utils/event'; +import {ApiResponseCode, SupportedLanguages, UnitType} from '../../../../api-def/api'; +import {Element} from '../../../../api-def/resources/types/element'; +import {Weapon} from '../../../../api-def/resources/types/weapon'; +import {translation as translationEN} from '../../../../i18n/translations/en/translation'; +import {ApiRequestSender} from '../../../../utils/services/api/requestSender'; +import {ResourceLoader} from '../../../../utils/services/resources/loader'; +import {UnitNameRefPage} from './main'; + + +describe('Name reference management', () => { + let fnGetRefs: jest.SpyInstance; + let fnSetRefs: jest.SpyInstance; + let fnGetUnitInfo: jest.SpyInstance; + + beforeEach(() => { + fnGetRefs = jest.spyOn(ApiRequestSender, 'getUnitNameRefManage').mockResolvedValue({ + code: ApiResponseCode.SUCCESS, + success: true, + refs: [ + { + unitId: 10950101, + name: 'G!Leon', + }, + { + unitId: 10950201, + name: 'Furis', + }, + ], + }); + fnSetRefs = jest.spyOn(ApiRequestSender, 'updateUnitNameRefs').mockResolvedValue({ + code: ApiResponseCode.SUCCESS, + success: true, + }); + fnGetUnitInfo = jest.spyOn(ResourceLoader, 'getCharacterInfo').mockResolvedValue([ + { + id: 10950101, + element: Element.FLAME, + cvEn: { + [SupportedLanguages.CHT]: 'CV EN CHT 1', + [SupportedLanguages.EN]: 'CV EN EN 1', + [SupportedLanguages.JP]: 'CV EN JP 1', + }, + cvJp: { + [SupportedLanguages.CHT]: 'CV JP CHT 1', + [SupportedLanguages.EN]: 'CV JP EN 1', + [SupportedLanguages.JP]: 'CV JP JP 1', + }, + hasUniqueDragon: false, + iconName: 'icon 1', + name: { + [SupportedLanguages.CHT]: 'CHARA CHT 1', + [SupportedLanguages.EN]: 'CHARA EN 1', + [SupportedLanguages.JP]: 'CHARA JP 1', + }, + rarity: 5, + releaseEpoch: 0, + weapon: Weapon.SWORD, + type: UnitType.CHARACTER, + }, + { + id: 10950201, + element: Element.FLAME, + cvEn: { + [SupportedLanguages.CHT]: 'CV EN CHT 2', + [SupportedLanguages.EN]: 'CV EN EN 2', + [SupportedLanguages.JP]: 'CV EN JP 2', + }, + cvJp: { + [SupportedLanguages.CHT]: 'CV JP CHT 2', + [SupportedLanguages.EN]: 'CV JP EN 2', + [SupportedLanguages.JP]: 'CV JP JP 2', + }, + hasUniqueDragon: false, + iconName: 'icon 2', + name: { + [SupportedLanguages.CHT]: 'CHARA CHT 2', + [SupportedLanguages.EN]: 'CHARA EN 2', + [SupportedLanguages.JP]: 'CHARA JP 2', + }, + rarity: 5, + releaseEpoch: 0, + weapon: Weapon.SWORD, + type: UnitType.CHARACTER, + }, + { + id: 10850101, + element: Element.FLAME, + cvEn: { + [SupportedLanguages.CHT]: 'CV EN CHT 3', + [SupportedLanguages.EN]: 'CV EN EN 3', + [SupportedLanguages.JP]: 'CV EN JP 3', + }, + cvJp: { + [SupportedLanguages.CHT]: 'CV JP CHT 3', + [SupportedLanguages.EN]: 'CV JP EN 3', + [SupportedLanguages.JP]: 'CV JP JP 3', + }, + hasUniqueDragon: false, + iconName: 'icon 3', + name: { + [SupportedLanguages.CHT]: 'CHARA CHT 3', + [SupportedLanguages.EN]: 'CHARA EN 3', + [SupportedLanguages.JP]: 'CHARA JP 3', + }, + rarity: 5, + releaseEpoch: 0, + weapon: Weapon.SWORD, + type: UnitType.CHARACTER, + }, + ]); + }); + + it('gets all references on load', async () => { + renderReact(() => , {hasSession: true, user: {isAdmin: true}}); + + await waitFor(() => expect(fnGetRefs).toHaveBeenCalled()); + }); + + it('disables update if any of the unit name is empty', async () => { + renderReact(() => , {hasSession: true, user: {isAdmin: true}}); + + const desiredNameInput = await screen.findByDisplayValue('G!Leon'); + userEvent.clear(desiredNameInput); + + expect(desiredNameInput).toHaveClass('is-invalid'); + + const updateButton = screen.getByText(translationEN.misc.update); + expect(updateButton).toBeDisabled(); + }); + + it('disables update if any of the unit ID is invalid', async () => { + const {rerender} = renderReact(() => , {hasSession: true, user: {isAdmin: true}}); + + const unitIdInput = await screen.findByDisplayValue('10950101'); + typeInput(unitIdInput, '1', {rerender}); + + expect(unitIdInput).toHaveClass('is-invalid'); + + const updateButton = screen.getByText(translationEN.misc.update); + expect(updateButton).toBeDisabled(); + }); + + it('allows update if no invalid input', async () => { + const {rerender} = renderReact(() => , {hasSession: true, user: {isAdmin: true}}); + await waitFor(() => expect(fnGetUnitInfo).toHaveBeenCalled()); + + const unitIdInput = await screen.findByDisplayValue('10950101'); + userEvent.clear(unitIdInput); + typeInput(unitIdInput, '10850101', {rerender}); + + await waitFor(() => expect(unitIdInput).toHaveClass('is-valid')); + + const updateButton = screen.getByText(translationEN.misc.update); + await waitFor(() => expect(updateButton).toBeEnabled()); + }); + + it('updates correctly', async () => { + const {rerender} = renderReact(() => , {hasSession: true, user: {isAdmin: true}}); + + const unitIdInput = await screen.findByDisplayValue('10950101'); + userEvent.clear(unitIdInput); + typeInput(unitIdInput, '10850101', {rerender}); + + const updateButton = screen.getByText(translationEN.misc.update); + userEvent.click(updateButton); + + // Correct data sent? + await waitFor(() => expect(fnSetRefs).toHaveBeenCalled()); + expect(fnSetRefs.mock.calls[0][2]).toStrictEqual([ + { + unitId: 10850101, + name: 'G!Leon', + }, + { + unitId: 10950201, + name: 'Furis', + }, + ]); + + // Updated marker shown? + expect(screen.getByText('', {selector: 'i.bi-cloud-check'})).toBeInTheDocument(); + + // Blocks re-update? + expect(updateButton).toBeDisabled(); + }); + + it('shows warning and does not block update on submission failed', async () => { + fnSetRefs.mockResolvedValueOnce({ + code: ApiResponseCode.FAILED_INTERNAL_ERROR, + success: false, + }); + + const {rerender} = renderReact(() => , {hasSession: true, user: {isAdmin: true}}); + + // Trigger update + const unitIdInput = await screen.findByDisplayValue('10950101'); + userEvent.clear(unitIdInput); + typeInput(unitIdInput, '10850101', {rerender}); + + const updateButton = screen.getByText(translationEN.misc.update); + userEvent.click(updateButton); + + expect(await screen.findByText('', {selector: 'i.bi-exclamation-circle'})).toBeInTheDocument(); + expect(screen.getByText(new RegExp(ApiResponseCode[ApiResponseCode.FAILED_INTERNAL_ERROR]))).toBeInTheDocument(); + }); + + it('disables update button on load', async () => { + renderReact(() => , {hasSession: true, user: {isAdmin: true}}); + + const updateButton = await screen.findByText(translationEN.misc.update); + expect(updateButton).toBeDisabled(); + }); + + it('enables update button after any change', async () => { + const {rerender} = renderReact(() => , {hasSession: true, user: {isAdmin: true}}); + + const unitIdInput = await screen.findByDisplayValue('10950101'); + userEvent.clear(unitIdInput); + typeInput(unitIdInput, '10850101', {rerender}); + + const updateButton = screen.getByText(translationEN.misc.update); + userEvent.click(updateButton); + + await waitFor(() => expect(fnSetRefs).toHaveBeenCalled()); + expect(screen.getByText('', {selector: 'i.bi-cloud-check'})).toBeInTheDocument(); + expect(updateButton).toBeDisabled(); + + userEvent.clear(unitIdInput); + typeInput(unitIdInput, '10950101', {rerender}); + expect(updateButton).toBeEnabled(); + }); +}); diff --git a/src/components/pages/gameData/nameRef/main.tsx b/src/components/pages/gameData/nameRef/main.tsx new file mode 100644 index 00000000..f64c5bc2 --- /dev/null +++ b/src/components/pages/gameData/nameRef/main.tsx @@ -0,0 +1,47 @@ +import React from 'react'; + +import {FailedResponse, isFailedResponse, UnitNameRefManageResponse} from '../../../../api-def/api'; +import {AppReactContext} from '../../../../context/app/main'; +import {useI18n} from '../../../../i18n/hook'; +import {ApiRequestSender} from '../../../../utils/services/api/requestSender'; +import {useFetchState} from '../../../elements/common/fetch'; +import {Loading} from '../../../elements/common/loading'; +import {AccessDenied} from '../../layout/accessDenied'; +import {ProtectedLayout} from '../../layout/protected'; +import {UnitNameRefManagement} from './manage'; + + +export const UnitNameRefPage = () => { + const {lang} = useI18n(); + const context = React.useContext(AppReactContext); + + if (!context?.session?.user.isAdmin) { + return ; + } + + const uid = context.session.user.id.toString(); + + const { + fetchStatus: unitRefs, + fetchFunction: fetchUnitRefs, + } = useFetchState( + undefined, + () => ApiRequestSender.getUnitNameRefManage(uid, lang), + 'Failed to fetch unit name references.', + ); + + fetchUnitRefs(); + + if (!unitRefs.fetched || !unitRefs.data || isFailedResponse(unitRefs.data)) { + return ; + } + + return ( + + + + ); +}; diff --git a/src/components/pages/gameData/nameRef/manage.tsx b/src/components/pages/gameData/nameRef/manage.tsx new file mode 100644 index 00000000..0d858cc8 --- /dev/null +++ b/src/components/pages/gameData/nameRef/manage.tsx @@ -0,0 +1,99 @@ +import React, {FormEvent} from 'react'; + +import Button from 'react-bootstrap/Button'; +import Col from 'react-bootstrap/Col'; +import Row from 'react-bootstrap/Row'; + +import { + ApiResponseCode, + UnitNameRefManageResponse, + UnitNameRefEntry as UnitNameRefEntryApi, ApiResponseCodeUtil, +} from '../../../../api-def/api'; +import {useI18n} from '../../../../i18n/hook'; +import {overrideObject} from '../../../../utils/override'; +import {ApiRequestSender} from '../../../../utils/services/api/requestSender'; +import {useUnitInfo} from '../../../../utils/services/resources/unitInfo/hooks'; +import {ArrayDataForm} from '../../../elements/posts/shared/form/array'; +import {UnitNameRefEntry} from './entry'; +import {NameRefUpdateStatus} from './status'; + + +export type RefsState = { + refs: UnitNameRefManageResponse['refs'], + updateStatus: null | ApiResponseCode, + updating: boolean, + isInit: boolean, +} + +export type RefsManagementProps = { + refs: UnitNameRefManageResponse['refs'], + uid: string +} + +export const UnitNameRefManagement = ({refs, uid}: RefsManagementProps) => { + const {t, lang} = useI18n(); + + const [refsStatus, setRefsStatus] = React.useState({ + refs, + updateStatus: null, + updating: false, + isInit: true, + }); + const {unitInfoMap} = useUnitInfo(); + const isValid = refsStatus.refs.every((entry) => !!unitInfoMap.get(entry.unitId) && !!entry.name); + const isJustUpdated = !!refsStatus.updateStatus && ApiResponseCodeUtil.isSuccess(refsStatus.updateStatus); + + const generateNewElement: () => UnitNameRefEntryApi = () => ({ + unitId: 0, + name: '', + }); + + const onSubmit = (e: FormEvent) => { + setRefsStatus(overrideObject(refsStatus, {updating: true})); + e.preventDefault(); + + ApiRequestSender.updateUnitNameRefs(uid, lang, refsStatus.refs) + .then((response) => { + setRefsStatus(overrideObject(refsStatus, {updateStatus: response.code, updating: false})); + }) + .catch((error) => { + setRefsStatus(overrideObject( + refsStatus, + { + updateStatus: ApiResponseCode.FAILED_INTERNAL_ERROR, + updating: false, + }, + )); + console.error(error); + }); + }; + + return ( +
+ data.refs} + setArray={(refs) => setRefsStatus(overrideObject(refsStatus, {refs, updateStatus: null, isInit: false}))} + getUpdatedElement={(element, key, newValue) => ({ + ...element, + [key]: newValue, + })} + generateNewElement={generateNewElement} + renderEntries={(element, onChange) => } + /> +
+ + + + + + + + ); +}; diff --git a/src/components/pages/gameData/nameRef/status.tsx b/src/components/pages/gameData/nameRef/status.tsx new file mode 100644 index 00000000..d2b295c7 --- /dev/null +++ b/src/components/pages/gameData/nameRef/status.tsx @@ -0,0 +1,33 @@ +import React from 'react'; + +import {ApiResponseCode, ApiResponseCodeUtil} from '../../../../api-def/api'; +import {useI18n} from '../../../../i18n/hook'; + + +type Props = { + status: ApiResponseCode | null, +} + +export const NameRefUpdateStatus = ({status}: Props) => { + const {t} = useI18n(); + + if (!status) { + return <>; + } + + if (ApiResponseCodeUtil.isSuccess(status)) { + return ( + +   + {t((t) => t.game.nameRef.status.updated)} + + ); + } + + return ( + +   + {t((t) => t.game.nameRef.status.error, {error: ApiResponseCode[status]})} + + ); +}; diff --git a/src/const/path/definitions.ts b/src/const/path/definitions.ts index ad03338d..81d3ab80 100644 --- a/src/const/path/definitions.ts +++ b/src/const/path/definitions.ts @@ -32,6 +32,8 @@ export enum GeneralPath { STORY = '/story', // Tools ROTATION_CALC = '/rotations', + // Other data + UNIT_NAME_REF = '/name', // Not game related ABOUT = '/about', SPECIAL_THANKS = '/thanks', diff --git a/src/i18n/translations/cht/translation.ts b/src/i18n/translations/cht/translation.ts index 8608bd5e..748a1428 100644 --- a/src/i18n/translations/cht/translation.ts +++ b/src/i18n/translations/cht/translation.ts @@ -396,6 +396,18 @@ export const translation: TranslationStruct = { relatedLinks: '相關連結', }, }, + nameRef: { + unitId: '物件 ID', + actualName: '實際名稱', + desiredName: '自訂名稱', + error: { + invalidUnitId: '物件 ID 不存在', + }, + status: { + updated: '更新成功!', + error: '更新失敗: {{error}}', + }, + }, }, userControl: { noUid: '無使用者 ID', @@ -442,6 +454,7 @@ export const translation: TranslationStruct = { openGif: '點擊以開啟 GIF 圖片', search: '搜尋', searchKeyword: '關鍵字', + update: '更新', }, meta: { inUse: { @@ -524,6 +537,10 @@ export const translation: TranslationStruct = { title: '{{unitName}}', description: '{{unitName}} 的相關資訊。', }, + name: { + title: '物件名稱設定', + description: '設定物件名稱的頁面。', + }, }, }, error: { diff --git a/src/i18n/translations/definition.ts b/src/i18n/translations/definition.ts index bf0b6d3c..70f83267 100644 --- a/src/i18n/translations/definition.ts +++ b/src/i18n/translations/definition.ts @@ -389,6 +389,18 @@ export type TranslationStruct = { relatedLinks: string, }, }, + nameRef: { + unitId: string, + actualName: string, + desiredName: string, + error: { + invalidUnitId: string, + }, + status: { + updated: string, + error: string, + }, + }, }, userControl: { noUid: string, @@ -426,6 +438,7 @@ export type TranslationStruct = { openGif: string, search: string, searchKeyword: string, + update: string, }, meta: { inUse: { @@ -457,6 +470,7 @@ export type TranslationStruct = { }, unit: { info: PageMetaTranslations, + name: PageMetaTranslations, }, }, error: { diff --git a/src/i18n/translations/en/translation.ts b/src/i18n/translations/en/translation.ts index 738dd04e..d39a174d 100644 --- a/src/i18n/translations/en/translation.ts +++ b/src/i18n/translations/en/translation.ts @@ -428,6 +428,18 @@ export const translation: TranslationStruct = { relatedLinks: 'Related Links', }, }, + nameRef: { + unitId: 'Unit ID', + actualName: 'Actual Name', + desiredName: 'Desired Name', + error: { + invalidUnitId: 'Invalid Unit #', + }, + status: { + updated: 'Updated!', + error: 'Failed to update: {{error}}', + }, + }, }, userControl: { noUid: 'No user ID', @@ -478,6 +490,7 @@ export const translation: TranslationStruct = { openGif: 'Click to open GIF', search: 'Search', searchKeyword: 'Keyword', + update: 'Update', }, meta: { inUse: { @@ -560,6 +573,10 @@ export const translation: TranslationStruct = { title: '{{unitName}}', description: 'Unit info of {{unitName}}.', }, + name: { + title: 'Unit Name Config', + description: 'Page to configure the custom unit names.', + }, }, }, error: { diff --git a/src/i18n/translations/jp/translation.ts b/src/i18n/translations/jp/translation.ts index e3076ba7..44d7585f 100644 --- a/src/i18n/translations/jp/translation.ts +++ b/src/i18n/translations/jp/translation.ts @@ -403,6 +403,18 @@ export const translation: TranslationStruct = { relatedLinks: 'に関するリンク', }, }, + nameRef: { + unitId: 'Unit ID', + actualName: 'Actual Name', + desiredName: 'Desired Name', + error: { + invalidUnitId: 'Invalid Unit #', + }, + status: { + updated: '更新成功!', + error: '更新失敗: {{error}}', + }, + }, }, userControl: { noUid: '該当するアカウントが見当たりません。', @@ -447,6 +459,7 @@ export const translation: TranslationStruct = { openGif: '點擊以開啟 GIF 圖片', search: '検索', searchKeyword: 'Keyword', + update: '更新', }, meta: { inUse: { @@ -529,6 +542,10 @@ export const translation: TranslationStruct = { title: '{{unitName}}', description: 'Unit info of {{unitName}}.', }, + name: { + title: 'ユニット名前設定ページ', + description: 'ユニット名前を設定する。', + }, }, }, error: { diff --git a/src/utils/meta/translations.ts b/src/utils/meta/translations.ts index 1e2fbb6c..883f731c 100644 --- a/src/utils/meta/translations.ts +++ b/src/utils/meta/translations.ts @@ -20,6 +20,7 @@ export const metaTransFunctions: { [path in PagePath]: GetTranslationFunction t.meta.inUse.gameData.skillAtk, [GeneralPath.ABOUT]: (t) => t.meta.inUse.about, [GeneralPath.SPECIAL_THANKS]: (t) => t.meta.inUse.thanks, + [GeneralPath.UNIT_NAME_REF]: (t) => t.meta.inUse.unit.name, [AuthPath.SIGN_IN]: (t) => t.meta.inUse.auth.signIn, // Constructing paths [PostPath.MISC]: (t) => t.meta.temp.constructing, diff --git a/src/utils/services/api/requestSender.ts b/src/utils/services/api/requestSender.ts index f22a7451..1541a185 100644 --- a/src/utils/services/api/requestSender.ts +++ b/src/utils/services/api/requestSender.ts @@ -42,8 +42,12 @@ import { UnitInfoLookupLandingResponse, UnitInfoLookupPayload, UnitInfoLookupResponse, + UnitNameRefManagePayload, + UnitNameRefManageResponse, UnitNameRefPayload, UnitNameRefResponse, + UnitNameRefUpdatePayload, + UnitNameRefUpdateResponse, UnitPageMetaPayload, UnitPageMetaResponse, UnitType, @@ -349,6 +353,37 @@ export class ApiRequestSender { ); } + /** + * Get all unit name references in a certain ``lang`` to update. + * + * @param {string} uid user ID + * @param {SupportedLanguages} lang language to get the unit name references + * @return {Promise} promise returned from `fetch` + */ + static getUnitNameRefManage(uid: string, lang: SupportedLanguages) { + return ApiRequestSender.sendRequest( + 'GET', + ApiEndPoints.INFO_UNIT_NAME_REF, + {uid, lang}, + ); + } + + /** + * Update all unit name references in a certain ``lang``. + * + * @param {string} uid user ID + * @param {SupportedLanguages} lang language to get the unit name references + * @param {Array} refs all unit name references in a certain language + * @return {Promise} promise returned from `fetch` + */ + static updateUnitNameRefs(uid: string, lang: SupportedLanguages, refs: UnitNameRefManageResponse['refs']) { + return ApiRequestSender.sendRequest( + 'POST', + ApiEndPoints.INFO_UNIT_NAME_REF, + {uid, lang, refs}, + ); + } + // endregion // region Preset