Add ability to remove job skills (#317)

* Add Awakening type and remove old defs

We remove the flat list of awakening data, as we will be pulling data from the database

* Update types to use new Awakening type

* Update WeaponUnit for Grand weapon awakenings

* Update object modals

We needed to update CharacterModal and WeaponModal to display awakenings from the new data format. However, the component used (`SelectWithInput`) was tied to AX Skills in a way that would take exponentially more time to resolve.

Instead, we forked `SelectWithInput` into `AwakeningSelectWithInput` and did our work there.

`AwakeningSelect` was found to be redundant, so it was removed.

* Update hovercards

* Add max-height to Select

* Allow styling of Select modal with className prop

* Add Job class to Job select

* Add localizations for removing job skills

* Add endpoint for removing job skills

* Implement removing job skills

We added a (...) button next to each editable job skill that opens a context menu that will allow the user to remove the job skill. An alert is presented to make sure the user is sure before proceeding.

As part of this change, some minor restyling of JobSkillItem was necessary
This commit is contained in:
Justin Edmund 2023-06-19 02:39:27 -07:00 committed by GitHub
parent 3f51ac0e7c
commit 739c72f4e0
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
11 changed files with 239 additions and 60 deletions

View file

@ -259,6 +259,23 @@ const CharacterGrid = (props: Props) => {
} }
} }
function removeJobSkill(position: number) {
if (party.id && props.editable) {
api
.removeJobSkill({ partyId: party.id, position: position })
.then((response) => {
// Update the current skills
const newSkills = response.data.job_skills
setJobSkills(newSkills)
appState.party.jobSkills = newSkills
})
.catch((error) => {
const data = error.response.data
console.log(data)
})
}
}
async function saveAccessory(accessory: JobAccessory) { async function saveAccessory(accessory: JobAccessory) {
const payload = { const payload = {
party: { party: {
@ -506,6 +523,7 @@ const CharacterGrid = (props: Props) => {
editable={props.editable} editable={props.editable}
saveJob={saveJob} saveJob={saveJob}
saveSkill={saveJobSkill} saveSkill={saveJobSkill}
removeSkill={removeJobSkill}
saveAccessory={saveAccessory} saveAccessory={saveAccessory}
/> />
<CharacterConflictModal <CharacterConflictModal

View file

@ -78,8 +78,10 @@
border: 1px solid rgba(0, 0, 0, 0.24); border: 1px solid rgba(0, 0, 0, 0.24);
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.16); box-shadow: 0 2px 8px rgba(0, 0, 0, 0.16);
padding: 0 $unit; padding: 0 $unit;
z-index: 40;
min-width: var(--radix-select-trigger-width); min-width: var(--radix-select-trigger-width);
max-height: 40vh;
z-index: 40;
.Scroll.Up, .Scroll.Up,
.Scroll.Down { .Scroll.Down {
padding: $unit 0; padding: $unit 0;

View file

@ -93,7 +93,7 @@ const Select = React.forwardRef<HTMLButtonElement, Props>(function Select(
)} )}
</RadixSelect.Trigger> </RadixSelect.Trigger>
<RadixSelect.Portal className="Select"> <RadixSelect.Portal className="SelectPortal">
<> <>
<Overlay <Overlay
open={open} open={open}
@ -101,7 +101,7 @@ const Select = React.forwardRef<HTMLButtonElement, Props>(function Select(
/> />
<RadixSelect.Content <RadixSelect.Content
className="Select" className={classNames({ Select: true }, props.className)}
position="popper" position="popper"
sideOffset={6} sideOffset={6}
onCloseAutoFocus={onCloseAutoFocus} onCloseAutoFocus={onCloseAutoFocus}

View file

@ -124,7 +124,9 @@ const JobDropdown = React.forwardRef<HTMLSelectElement, Props>(
onClick={openJobSelect} onClick={openJobSelect}
onOpenChange={() => setOpen(!open)} onOpenChange={() => setOpen(!open)}
onValueChange={handleChange} onValueChange={handleChange}
className="Job"
triggerClass="Job" triggerClass="Job"
overlayVisible={false}
> >
<SelectItem key={-1} value="no-job"> <SelectItem key={-1} value="no-job">
{t('no_job')} {t('no_job')}

View file

@ -56,6 +56,9 @@
.JobSkills { .JobSkills {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
gap: $unit;
&:not(.editable) {
gap: $unit;
}
} }
} }

View file

@ -2,6 +2,7 @@ import React, { useEffect, useState } from 'react'
import { useRouter } from 'next/router' import { useRouter } from 'next/router'
import { useSnapshot } from 'valtio' import { useSnapshot } from 'valtio'
import { useTranslation } from 'next-i18next' import { useTranslation } from 'next-i18next'
import classNames from 'classnames'
import JobDropdown from '~components/job/JobDropdown' import JobDropdown from '~components/job/JobDropdown'
import JobImage from '~components/job/JobImage' import JobImage from '~components/job/JobImage'
@ -22,6 +23,7 @@ interface Props {
editable: boolean editable: boolean
saveJob: (job?: Job) => void saveJob: (job?: Job) => void
saveSkill: (skill: JobSkill, position: number) => void saveSkill: (skill: JobSkill, position: number) => void
removeSkill: (position: number) => void
saveAccessory: (accessory: JobAccessory) => void saveAccessory: (accessory: JobAccessory) => void
} }
@ -48,6 +50,12 @@ const JobSection = (props: Props) => {
// Refs // Refs
const selectRef = React.createRef<HTMLSelectElement>() const selectRef = React.createRef<HTMLSelectElement>()
// Classes
const skillContainerClasses = classNames({
JobSkills: true,
editable: props.editable,
})
useEffect(() => { useEffect(() => {
// Set current job based on ID // Set current job based on ID
setJob(props.job) setJob(props.job)
@ -126,9 +134,11 @@ const JobSection = (props: Props) => {
return ( return (
<JobSkillItem <JobSkillItem
skill={skills[index]} skill={skills[index]}
position={index}
editable={canEditSkill(skills[index])} editable={canEditSkill(skills[index])}
key={`skill-${index}`} key={`skill-${index}`}
hasJob={job != undefined && job.id != '-1'} hasJob={job != undefined && job.id != '-1'}
removeJobSkill={props.removeSkill}
/> />
) )
} }
@ -173,10 +183,6 @@ const JobSection = (props: Props) => {
</div> </div>
) )
function jobLabel() {
return job ? filledJobLabel : emptyJobLabel
}
// Render: JSX components // Render: JSX components
return ( return (
<section id="Job"> <section id="Job">
@ -209,7 +215,7 @@ const JobSection = (props: Props) => {
</div> </div>
)} )}
<ul className="JobSkills"> <ul className={skillContainerClasses}>
{[...Array(numSkills)].map((e, i) => ( {[...Array(numSkills)].map((e, i) => (
<li key={`job-${i}`}> <li key={`job-${i}`}>
{canEditSkill(skills[i]) {canEditSkill(skills[i])

View file

@ -1,47 +1,81 @@
.JobSkills {
&.editable .JobSkill {
.Info {
padding: $unit-half * 1.5;
& > img,
& > div.placeholder {
width: $unit-4x;
height: $unit-4x;
}
}
}
}
.JobSkill { .JobSkill {
display: flex; display: flex;
gap: $unit; align-items: stretch;
align-items: center; justify-content: space-between;
&.editable .Info:hover {
background-color: var(--button-bg-hover);
}
&.editable:hover { &.editable:hover {
cursor: pointer; cursor: pointer;
& > img.editable, .Info {
& > div.placeholder.editable { & > img.editable,
border: $hover-stroke; & > div.placeholder.editable {
box-shadow: $hover-shadow; border: $hover-stroke;
cursor: pointer; box-shadow: $hover-shadow;
transform: $scale-tall; cursor: pointer;
} transform: $scale-tall;
}
& p.placeholder { & p.placeholder {
color: var(--text-tertiary-hover); color: var(--text-tertiary-hover);
} }
& svg { & svg {
fill: var(--icon-secondary-hover); fill: var(--icon-secondary-hover);
}
} }
} }
& > img, .Info {
& > div.placeholder {
background: var(--card-bg);
border-radius: calc($unit / 2);
border: 1px solid rgba(0, 0, 0, 0);
width: $unit * 5;
height: $unit * 5;
}
& > div.placeholder {
display: flex;
align-items: center; align-items: center;
justify-content: center; border-radius: $input-corner;
display: flex;
flex-grow: 1;
gap: $unit;
& > svg { & > img,
fill: var(--icon-secondary); & > div.placeholder {
width: $unit * 2; background: var(--card-bg);
height: $unit * 2; border-radius: calc($unit / 2);
border: 1px solid rgba(0, 0, 0, 0);
width: $unit-5x;
height: $unit-5x;
} }
& > div.placeholder {
display: flex;
align-items: center;
justify-content: center;
& > svg {
fill: var(--icon-secondary);
width: $unit-2x;
height: $unit-2x;
}
}
}
& > .Button {
justify-content: center;
max-width: $unit-6x;
height: auto;
} }
p { p {

View file

@ -1,21 +1,43 @@
import React from 'react' import React, { useState } from 'react'
import { useRouter } from 'next/router' import { useRouter } from 'next/router'
import { useTranslation } from 'next-i18next' import { Trans, useTranslation } from 'next-i18next'
import classNames from 'classnames' import classNames from 'classnames'
import PlusIcon from '~public/icons/Add.svg'
import Alert from '~components/common/Alert'
import Button from '~components/common/Button'
import {
ContextMenu,
ContextMenuTrigger,
ContextMenuContent,
} from '~components/common/ContextMenu'
import ContextMenuItem from '~components/common/ContextMenuItem'
import EllipsisIcon from '~public/icons/Ellipsis.svg'
import PlusIcon from '~public/icons/Add.svg'
import './index.scss' import './index.scss'
// Props // Props
interface Props extends React.ComponentPropsWithoutRef<'div'> { interface Props extends React.ComponentPropsWithoutRef<'div'> {
skill?: JobSkill skill?: JobSkill
position: number
editable: boolean editable: boolean
hasJob: boolean hasJob: boolean
removeJobSkill: (position: number) => void
} }
const JobSkillItem = React.forwardRef<HTMLDivElement, Props>( const JobSkillItem = React.forwardRef<HTMLDivElement, Props>(
function useJobSkillItem({ ...props }, forwardedRef) { function useJobSkillItem(
{
skill,
position,
editable,
hasJob,
removeJobSkill: sendJobSkillToRemove,
...props
},
forwardedRef
) {
// Set up translation
const router = useRouter() const router = useRouter()
const { t } = useTranslation('common') const { t } = useTranslation('common')
const locale = const locale =
@ -23,31 +45,55 @@ const JobSkillItem = React.forwardRef<HTMLDivElement, Props>(
? router.locale ? router.locale
: 'en' : 'en'
// States: Component
const [alertOpen, setAlertOpen] = useState(false)
const [contextMenuOpen, setContextMenuOpen] = useState(false)
// Classes
const classes = classNames({ const classes = classNames({
JobSkill: true, JobSkill: true,
editable: props.editable, editable: editable,
}) })
const imageClasses = classNames({ const imageClasses = classNames({
placeholder: !props.skill, placeholder: !skill,
editable: props.editable && props.hasJob, editable: editable && hasJob,
}) })
const buttonClasses = classNames({
Clicked: contextMenuOpen,
})
// Methods: Data mutation
function removeJobSkill() {
if (skill) sendJobSkillToRemove(position)
setAlertOpen(false)
}
// Methods: Context menu
function handleButtonClicked() {
setContextMenuOpen(!contextMenuOpen)
}
function handleContextMenuOpenChange(open: boolean) {
if (!open) setContextMenuOpen(false)
}
const skillImage = () => { const skillImage = () => {
let jsx: React.ReactNode let jsx: React.ReactNode
if (props.skill) { if (skill) {
jsx = ( jsx = (
<img <img
alt={props.skill.name[locale]} alt={skill.name[locale]}
className={imageClasses} className={imageClasses}
src={`${process.env.NEXT_PUBLIC_SIERO_IMG_URL}/job-skills/${props.skill.slug}.png`} src={`${process.env.NEXT_PUBLIC_SIERO_IMG_URL}/job-skills/${skill.slug}.png`}
/> />
) )
} else { } else {
jsx = ( jsx = (
<div className={imageClasses}> <div className={imageClasses}>
{props.editable && props.hasJob ? <PlusIcon /> : ''} {editable && hasJob ? <PlusIcon /> : ''}
</div> </div>
) )
} }
@ -58,9 +104,9 @@ const JobSkillItem = React.forwardRef<HTMLDivElement, Props>(
const label = () => { const label = () => {
let jsx: React.ReactNode let jsx: React.ReactNode
if (props.skill) { if (skill) {
jsx = <p>{props.skill.name[locale]}</p> jsx = <p>{skill.name[locale]}</p>
} else if (props.editable && props.hasJob) { } else if (editable && hasJob) {
jsx = <p className="placeholder">{t('job_skills.state.selectable')}</p> jsx = <p className="placeholder">{t('job_skills.state.selectable')}</p>
} else { } else {
jsx = <p className="placeholder">{t('job_skills.state.no_skill')}</p> jsx = <p className="placeholder">{t('job_skills.state.no_skill')}</p>
@ -69,10 +115,55 @@ const JobSkillItem = React.forwardRef<HTMLDivElement, Props>(
return jsx return jsx
} }
const removeAlert = () => {
return (
<Alert
open={alertOpen}
primaryAction={removeJobSkill}
primaryActionText={t('modals.job_skills.buttons.remove')}
cancelAction={() => setAlertOpen(false)}
cancelActionText={t('buttons.cancel')}
message={
<Trans i18nKey="modals.job_skills.messages.remove">
Are you sure you want to remove{' '}
<strong>{{ job_skill: skill?.name[locale] }}</strong> from your
team?
</Trans>
}
/>
)
}
const contextMenu = () => {
return (
<>
<ContextMenu onOpenChange={handleContextMenuOpenChange}>
<ContextMenuTrigger asChild>
<Button
leftAccessoryIcon={<EllipsisIcon />}
className={buttonClasses}
blended={true}
onClick={handleButtonClicked}
/>
</ContextMenuTrigger>
<ContextMenuContent align="start">
<ContextMenuItem onSelect={() => setAlertOpen(true)}>
{t('context.remove_job_skill')}
</ContextMenuItem>
</ContextMenuContent>
</ContextMenu>
{removeAlert()}
</>
)
}
return ( return (
<div className={classes} onClick={props.onClick} ref={forwardedRef}> <div className={classes} ref={forwardedRef}>
{skillImage()} <div className="Info" onClick={props.onClick} tabIndex={0}>
{label()} {skillImage()}
{label()}
</div>
{skill && editable && contextMenu()}
</div> </div>
) )
} }

View file

@ -55,7 +55,8 @@
"summon": "Modify summon", "summon": "Modify summon",
"weapon": "Modify weapon" "weapon": "Modify weapon"
}, },
"remove": "Remove from grid" "remove": "Remove from grid",
"remove_job_skill": "Remove class skill"
}, },
"elements": { "elements": {
"null": "Null", "null": "Null",
@ -240,6 +241,14 @@
"remove": "Remove guidebook" "remove": "Remove guidebook"
} }
}, },
"job_skills": {
"messages": {
"remove": "Are you sure you want to remove <strong>{{job_skill}}</strong> from your team?"
},
"buttons": {
"remove": "Remove class skill"
}
},
"login": { "login": {
"title": "Log in", "title": "Log in",
"buttons": { "buttons": {
@ -442,7 +451,7 @@
"weapon": "Search for a weapon...", "weapon": "Search for a weapon...",
"summon": "Search for a summon...", "summon": "Search for a summon...",
"character": "Search for a character...", "character": "Search for a character...",
"job_skill": "Search job skills...", "job_skill": "Search class skills...",
"guidebook": "Search guidebooks...", "guidebook": "Search guidebooks...",
"raid": "Search battles..." "raid": "Search battles..."
} }

View file

@ -55,7 +55,8 @@
"summon": "召喚石を変更", "summon": "召喚石を変更",
"weapon": "武器を変更" "weapon": "武器を変更"
}, },
"remove": "編成から削除" "remove": "編成から削除",
"remove_job_skill": "ジョブスキルを削除"
}, },
"elements": { "elements": {
"null": "無", "null": "無",
@ -236,6 +237,14 @@
"remove": "導本を削除する" "remove": "導本を削除する"
} }
}, },
"job_skills": {
"messages": {
"remove": "<strong>{{job_skill}}</strong>を編成から削除しますか?"
},
"buttons": {
"remove": "ジョブスキルを削除する"
}
},
"login": { "login": {
"title": "ログイン", "title": "ログイン",
"buttons": { "buttons": {

View file

@ -105,6 +105,11 @@ class Api {
return axios.put(resourceUrl, params) return axios.put(resourceUrl, params)
} }
removeJobSkill({ partyId, position, params }: { partyId: string, position: number, params?: {} }) {
const resourceUrl = `${this.url}/parties/${partyId}/job_skills`
return axios.delete(resourceUrl, { data: { party: { skill_position: position } }, headers: params })
}
allJobSkills(params?: {}) { allJobSkills(params?: {}) {
const resourceUrl = `${this.url}/jobs/skills` const resourceUrl = `${this.url}/jobs/skills`
return axios.get(resourceUrl, params) return axios.get(resourceUrl, params)