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:
parent
3f51ac0e7c
commit
739c72f4e0
11 changed files with 239 additions and 60 deletions
|
|
@ -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) {
|
||||
const payload = {
|
||||
party: {
|
||||
|
|
@ -506,6 +523,7 @@ const CharacterGrid = (props: Props) => {
|
|||
editable={props.editable}
|
||||
saveJob={saveJob}
|
||||
saveSkill={saveJobSkill}
|
||||
removeSkill={removeJobSkill}
|
||||
saveAccessory={saveAccessory}
|
||||
/>
|
||||
<CharacterConflictModal
|
||||
|
|
|
|||
|
|
@ -78,8 +78,10 @@
|
|||
border: 1px solid rgba(0, 0, 0, 0.24);
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.16);
|
||||
padding: 0 $unit;
|
||||
z-index: 40;
|
||||
min-width: var(--radix-select-trigger-width);
|
||||
max-height: 40vh;
|
||||
z-index: 40;
|
||||
|
||||
.Scroll.Up,
|
||||
.Scroll.Down {
|
||||
padding: $unit 0;
|
||||
|
|
|
|||
|
|
@ -93,7 +93,7 @@ const Select = React.forwardRef<HTMLButtonElement, Props>(function Select(
|
|||
)}
|
||||
</RadixSelect.Trigger>
|
||||
|
||||
<RadixSelect.Portal className="Select">
|
||||
<RadixSelect.Portal className="SelectPortal">
|
||||
<>
|
||||
<Overlay
|
||||
open={open}
|
||||
|
|
@ -101,7 +101,7 @@ const Select = React.forwardRef<HTMLButtonElement, Props>(function Select(
|
|||
/>
|
||||
|
||||
<RadixSelect.Content
|
||||
className="Select"
|
||||
className={classNames({ Select: true }, props.className)}
|
||||
position="popper"
|
||||
sideOffset={6}
|
||||
onCloseAutoFocus={onCloseAutoFocus}
|
||||
|
|
|
|||
|
|
@ -124,7 +124,9 @@ const JobDropdown = React.forwardRef<HTMLSelectElement, Props>(
|
|||
onClick={openJobSelect}
|
||||
onOpenChange={() => setOpen(!open)}
|
||||
onValueChange={handleChange}
|
||||
className="Job"
|
||||
triggerClass="Job"
|
||||
overlayVisible={false}
|
||||
>
|
||||
<SelectItem key={-1} value="no-job">
|
||||
{t('no_job')}
|
||||
|
|
|
|||
|
|
@ -56,6 +56,9 @@
|
|||
.JobSkills {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: $unit;
|
||||
|
||||
&:not(.editable) {
|
||||
gap: $unit;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@ import React, { useEffect, useState } from 'react'
|
|||
import { useRouter } from 'next/router'
|
||||
import { useSnapshot } from 'valtio'
|
||||
import { useTranslation } from 'next-i18next'
|
||||
import classNames from 'classnames'
|
||||
|
||||
import JobDropdown from '~components/job/JobDropdown'
|
||||
import JobImage from '~components/job/JobImage'
|
||||
|
|
@ -22,6 +23,7 @@ interface Props {
|
|||
editable: boolean
|
||||
saveJob: (job?: Job) => void
|
||||
saveSkill: (skill: JobSkill, position: number) => void
|
||||
removeSkill: (position: number) => void
|
||||
saveAccessory: (accessory: JobAccessory) => void
|
||||
}
|
||||
|
||||
|
|
@ -48,6 +50,12 @@ const JobSection = (props: Props) => {
|
|||
// Refs
|
||||
const selectRef = React.createRef<HTMLSelectElement>()
|
||||
|
||||
// Classes
|
||||
const skillContainerClasses = classNames({
|
||||
JobSkills: true,
|
||||
editable: props.editable,
|
||||
})
|
||||
|
||||
useEffect(() => {
|
||||
// Set current job based on ID
|
||||
setJob(props.job)
|
||||
|
|
@ -126,9 +134,11 @@ const JobSection = (props: Props) => {
|
|||
return (
|
||||
<JobSkillItem
|
||||
skill={skills[index]}
|
||||
position={index}
|
||||
editable={canEditSkill(skills[index])}
|
||||
key={`skill-${index}`}
|
||||
hasJob={job != undefined && job.id != '-1'}
|
||||
removeJobSkill={props.removeSkill}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
|
@ -173,10 +183,6 @@ const JobSection = (props: Props) => {
|
|||
</div>
|
||||
)
|
||||
|
||||
function jobLabel() {
|
||||
return job ? filledJobLabel : emptyJobLabel
|
||||
}
|
||||
|
||||
// Render: JSX components
|
||||
return (
|
||||
<section id="Job">
|
||||
|
|
@ -209,7 +215,7 @@ const JobSection = (props: Props) => {
|
|||
</div>
|
||||
)}
|
||||
|
||||
<ul className="JobSkills">
|
||||
<ul className={skillContainerClasses}>
|
||||
{[...Array(numSkills)].map((e, i) => (
|
||||
<li key={`job-${i}`}>
|
||||
{canEditSkill(skills[i])
|
||||
|
|
|
|||
|
|
@ -1,47 +1,81 @@
|
|||
.JobSkills {
|
||||
&.editable .JobSkill {
|
||||
.Info {
|
||||
padding: $unit-half * 1.5;
|
||||
|
||||
& > img,
|
||||
& > div.placeholder {
|
||||
width: $unit-4x;
|
||||
height: $unit-4x;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.JobSkill {
|
||||
display: flex;
|
||||
gap: $unit;
|
||||
align-items: center;
|
||||
align-items: stretch;
|
||||
justify-content: space-between;
|
||||
|
||||
&.editable .Info:hover {
|
||||
background-color: var(--button-bg-hover);
|
||||
}
|
||||
|
||||
&.editable:hover {
|
||||
cursor: pointer;
|
||||
|
||||
& > img.editable,
|
||||
& > div.placeholder.editable {
|
||||
border: $hover-stroke;
|
||||
box-shadow: $hover-shadow;
|
||||
cursor: pointer;
|
||||
transform: $scale-tall;
|
||||
}
|
||||
.Info {
|
||||
& > img.editable,
|
||||
& > div.placeholder.editable {
|
||||
border: $hover-stroke;
|
||||
box-shadow: $hover-shadow;
|
||||
cursor: pointer;
|
||||
transform: $scale-tall;
|
||||
}
|
||||
|
||||
& p.placeholder {
|
||||
color: var(--text-tertiary-hover);
|
||||
}
|
||||
& p.placeholder {
|
||||
color: var(--text-tertiary-hover);
|
||||
}
|
||||
|
||||
& svg {
|
||||
fill: var(--icon-secondary-hover);
|
||||
& svg {
|
||||
fill: var(--icon-secondary-hover);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
& > img,
|
||||
& > 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;
|
||||
.Info {
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border-radius: $input-corner;
|
||||
display: flex;
|
||||
flex-grow: 1;
|
||||
gap: $unit;
|
||||
|
||||
& > svg {
|
||||
fill: var(--icon-secondary);
|
||||
width: $unit * 2;
|
||||
height: $unit * 2;
|
||||
& > img,
|
||||
& > div.placeholder {
|
||||
background: var(--card-bg);
|
||||
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 {
|
||||
|
|
|
|||
|
|
@ -1,21 +1,43 @@
|
|||
import React from 'react'
|
||||
import React, { useState } from 'react'
|
||||
import { useRouter } from 'next/router'
|
||||
import { useTranslation } from 'next-i18next'
|
||||
|
||||
import { Trans, useTranslation } from 'next-i18next'
|
||||
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'
|
||||
|
||||
// Props
|
||||
interface Props extends React.ComponentPropsWithoutRef<'div'> {
|
||||
skill?: JobSkill
|
||||
position: number
|
||||
editable: boolean
|
||||
hasJob: boolean
|
||||
removeJobSkill: (position: number) => void
|
||||
}
|
||||
|
||||
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 { t } = useTranslation('common')
|
||||
const locale =
|
||||
|
|
@ -23,31 +45,55 @@ const JobSkillItem = React.forwardRef<HTMLDivElement, Props>(
|
|||
? router.locale
|
||||
: 'en'
|
||||
|
||||
// States: Component
|
||||
const [alertOpen, setAlertOpen] = useState(false)
|
||||
const [contextMenuOpen, setContextMenuOpen] = useState(false)
|
||||
|
||||
// Classes
|
||||
const classes = classNames({
|
||||
JobSkill: true,
|
||||
editable: props.editable,
|
||||
editable: editable,
|
||||
})
|
||||
|
||||
const imageClasses = classNames({
|
||||
placeholder: !props.skill,
|
||||
editable: props.editable && props.hasJob,
|
||||
placeholder: !skill,
|
||||
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 = () => {
|
||||
let jsx: React.ReactNode
|
||||
|
||||
if (props.skill) {
|
||||
if (skill) {
|
||||
jsx = (
|
||||
<img
|
||||
alt={props.skill.name[locale]}
|
||||
alt={skill.name[locale]}
|
||||
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 {
|
||||
jsx = (
|
||||
<div className={imageClasses}>
|
||||
{props.editable && props.hasJob ? <PlusIcon /> : ''}
|
||||
{editable && hasJob ? <PlusIcon /> : ''}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
@ -58,9 +104,9 @@ const JobSkillItem = React.forwardRef<HTMLDivElement, Props>(
|
|||
const label = () => {
|
||||
let jsx: React.ReactNode
|
||||
|
||||
if (props.skill) {
|
||||
jsx = <p>{props.skill.name[locale]}</p>
|
||||
} else if (props.editable && props.hasJob) {
|
||||
if (skill) {
|
||||
jsx = <p>{skill.name[locale]}</p>
|
||||
} else if (editable && hasJob) {
|
||||
jsx = <p className="placeholder">{t('job_skills.state.selectable')}</p>
|
||||
} else {
|
||||
jsx = <p className="placeholder">{t('job_skills.state.no_skill')}</p>
|
||||
|
|
@ -69,10 +115,55 @@ const JobSkillItem = React.forwardRef<HTMLDivElement, Props>(
|
|||
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 (
|
||||
<div className={classes} onClick={props.onClick} ref={forwardedRef}>
|
||||
{skillImage()}
|
||||
{label()}
|
||||
<div className={classes} ref={forwardedRef}>
|
||||
<div className="Info" onClick={props.onClick} tabIndex={0}>
|
||||
{skillImage()}
|
||||
{label()}
|
||||
</div>
|
||||
{skill && editable && contextMenu()}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -55,7 +55,8 @@
|
|||
"summon": "Modify summon",
|
||||
"weapon": "Modify weapon"
|
||||
},
|
||||
"remove": "Remove from grid"
|
||||
"remove": "Remove from grid",
|
||||
"remove_job_skill": "Remove class skill"
|
||||
},
|
||||
"elements": {
|
||||
"null": "Null",
|
||||
|
|
@ -240,6 +241,14 @@
|
|||
"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": {
|
||||
"title": "Log in",
|
||||
"buttons": {
|
||||
|
|
@ -442,7 +451,7 @@
|
|||
"weapon": "Search for a weapon...",
|
||||
"summon": "Search for a summon...",
|
||||
"character": "Search for a character...",
|
||||
"job_skill": "Search job skills...",
|
||||
"job_skill": "Search class skills...",
|
||||
"guidebook": "Search guidebooks...",
|
||||
"raid": "Search battles..."
|
||||
}
|
||||
|
|
|
|||
|
|
@ -55,7 +55,8 @@
|
|||
"summon": "召喚石を変更",
|
||||
"weapon": "武器を変更"
|
||||
},
|
||||
"remove": "編成から削除"
|
||||
"remove": "編成から削除",
|
||||
"remove_job_skill": "ジョブスキルを削除"
|
||||
},
|
||||
"elements": {
|
||||
"null": "無",
|
||||
|
|
@ -236,6 +237,14 @@
|
|||
"remove": "導本を削除する"
|
||||
}
|
||||
},
|
||||
"job_skills": {
|
||||
"messages": {
|
||||
"remove": "<strong>{{job_skill}}</strong>を編成から削除しますか?"
|
||||
},
|
||||
"buttons": {
|
||||
"remove": "ジョブスキルを削除する"
|
||||
}
|
||||
},
|
||||
"login": {
|
||||
"title": "ログイン",
|
||||
"buttons": {
|
||||
|
|
|
|||
|
|
@ -105,6 +105,11 @@ class Api {
|
|||
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?: {}) {
|
||||
const resourceUrl = `${this.url}/jobs/skills`
|
||||
return axios.get(resourceUrl, params)
|
||||
|
|
|
|||
Loading…
Reference in a new issue