-
Notifications
You must be signed in to change notification settings - Fork 25
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
* Feat: Add edit group modal * Misc: Memoization of GroupEditModal tabs * Misc: Avoid padding on list items without overflowing content * Misc: Optionally apply consistent result sorting to GroupSeriesQuery * Misc: Implement optional sorting sensibly...
- Loading branch information
Showing
10 changed files
with
411 additions
and
30 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,75 @@ | ||
import React, { useEffect, useState } from 'react'; | ||
import { useDispatch, useSelector } from 'react-redux'; | ||
import cx from 'classnames'; | ||
import { map } from 'lodash'; | ||
|
||
import NameTab from '@/components/Collection/Group/EditGroupTabs/NameTab'; | ||
import SeriesTab from '@/components/Collection/Group/EditGroupTabs/SeriesTab'; | ||
import ModalPanel from '@/components/Panels/ModalPanel'; | ||
import { setGroupId } from '@/core/slices/modals/editGroup'; | ||
import useEventCallback from '@/hooks/useEventCallback'; | ||
|
||
import type { RootState } from '@/core/store'; | ||
|
||
const tabs = { | ||
name: 'Name', | ||
series: 'Series', | ||
}; | ||
|
||
const renderTab = (activeTab: string, groupId: number) => { | ||
if (groupId === -1) { | ||
return null; | ||
} | ||
|
||
switch (activeTab) { | ||
case 'series': | ||
return <SeriesTab groupId={groupId} />; | ||
case 'name': | ||
default: | ||
return <NameTab groupId={groupId} />; | ||
} | ||
}; | ||
|
||
const EditGroupModal = () => { | ||
const dispatch = useDispatch(); | ||
|
||
const onClose = useEventCallback(() => { | ||
dispatch(setGroupId(-1)); | ||
}); | ||
|
||
useEffect(() => onClose, [onClose]); | ||
|
||
const groupId = useSelector((state: RootState) => state.modals.editGroup.groupId); | ||
|
||
const [activeTab, setActiveTab] = useState<keyof typeof tabs>('name'); | ||
|
||
return ( | ||
<ModalPanel show={groupId !== -1} onRequestClose={onClose} header="Edit Group" size="md" noPadding noGap> | ||
<div className="flex h-[26rem] flex-row gap-x-6 p-6"> | ||
<div className="flex shrink-0 gap-y-6 font-semibold"> | ||
<div className="flex flex-col gap-y-1"> | ||
{map(tabs, (value: string, key: keyof typeof tabs) => ( | ||
<div | ||
className={cx( | ||
activeTab === key | ||
? 'w-[12rem] text-center bg-panel-menu-item-background p-3 rounded-lg text-panel-menu-item-text cursor-pointer' | ||
: 'w-[12rem] text-center p-3 rounded-lg hover:bg-panel-menu-item-background-hover cursor-pointer', | ||
)} | ||
key={key} | ||
onClick={() => setActiveTab(key)} | ||
> | ||
{value} | ||
</div> | ||
))} | ||
</div> | ||
</div> | ||
<div className="border-r border-panel-border" /> | ||
<div className="grow"> | ||
{renderTab(activeTab, groupId)} | ||
</div> | ||
</div> | ||
</ModalPanel> | ||
); | ||
}; | ||
|
||
export default EditGroupModal; |
138 changes: 138 additions & 0 deletions
138
src/components/Collection/Group/EditGroupTabs/NameTab.tsx
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,138 @@ | ||
import React, { useEffect, useMemo, useState } from 'react'; | ||
import { mdiCheckUnderlineCircleOutline, mdiCloseCircleOutline, mdiPencilCircleOutline } from '@mdi/js'; | ||
import cx from 'classnames'; | ||
import { useToggle } from 'usehooks-ts'; | ||
|
||
import Input from '@/components/Input/Input'; | ||
import { usePatchGroupMutation } from '@/core/react-query/group/mutations'; | ||
import { useGroupQuery, useGroupSeriesQuery } from '@/core/react-query/group/queries'; | ||
import useEventCallback from '@/hooks/useEventCallback'; | ||
|
||
type Props = { | ||
groupId: number; | ||
}; | ||
|
||
const NameTab = React.memo(({ groupId }: Props) => { | ||
const { | ||
data: groupData, | ||
isError: groupError, | ||
isFetching: groupFetching, | ||
isSuccess: groupSuccess, | ||
} = useGroupQuery(groupId); | ||
const { | ||
data: seriesData, | ||
isError: seriesError, | ||
isFetching: seriesFetching, | ||
isSuccess: seriesSuccess, | ||
} = useGroupSeriesQuery(groupId); | ||
|
||
const [groupName, setGroupName] = useState(groupData?.Name ?? ''); | ||
useEffect(() => { | ||
setGroupName(groupData?.Name ?? ''); | ||
}, [groupData?.Name]); | ||
|
||
const [nameEditable, toggleNameEditable] = useToggle(false); | ||
|
||
const { mutate: renameGroupMutation } = usePatchGroupMutation(); | ||
const renameGroup = useEventCallback(() => { | ||
if (groupName !== groupData?.Name) { | ||
renameGroupMutation( | ||
{ | ||
seriesId: 0, // Hack to avoid creating a new mutation... | ||
groupId, | ||
operations: [ | ||
{ op: 'replace', path: 'Name', value: groupName }, | ||
{ op: 'replace', path: 'HasCustomName', value: 'true' }, | ||
], | ||
}, | ||
); | ||
} | ||
toggleNameEditable(); | ||
}); | ||
|
||
const updateName = useEventCallback((e: React.ChangeEvent<HTMLInputElement>) => { | ||
setGroupName(e.target.value); | ||
}); | ||
|
||
const resetName = useEventCallback(() => { | ||
toggleNameEditable(); | ||
setGroupName(groupData?.Name ?? ''); | ||
}); | ||
|
||
const nameInputIcons = useMemo(() => { | ||
if (!nameEditable || groupFetching || seriesFetching) { | ||
return [{ | ||
icon: mdiPencilCircleOutline, | ||
className: 'text-panel-text-primary', | ||
onClick: toggleNameEditable, | ||
tooltip: 'Edit name', | ||
}]; | ||
} | ||
|
||
if (!(groupSuccess && seriesSuccess)) return []; | ||
|
||
return [ | ||
{ | ||
icon: mdiCloseCircleOutline, | ||
className: 'text-panel-text-danger', | ||
onClick: resetName, | ||
tooltip: 'Cancel', | ||
}, | ||
{ | ||
icon: mdiCheckUnderlineCircleOutline, | ||
className: 'text-panel-text-success', | ||
onClick: renameGroup, | ||
tooltip: 'Confirm', | ||
}, | ||
]; | ||
}, [ | ||
groupFetching, | ||
groupSuccess, | ||
nameEditable, | ||
renameGroup, | ||
resetName, | ||
seriesFetching, | ||
seriesSuccess, | ||
toggleNameEditable, | ||
]); | ||
|
||
return ( | ||
<div className="flex h-full flex-col"> | ||
{(groupError || seriesError) && ( | ||
<div className="m-auto text-lg font-semibold text-panel-text-danger"> | ||
Group & Series data could not be loaded! | ||
</div> | ||
)} | ||
|
||
<Input | ||
id="group-name" | ||
type="text" | ||
onChange={updateName} | ||
value={groupName} | ||
placeholder={(groupFetching || seriesFetching) ? 'Loading...' : undefined} | ||
label="Name" | ||
className="mb-4" | ||
inputClassName={cx(nameInputIcons.length > 1 ? 'pr-[5rem]' : 'pr-12', 'truncate')} | ||
endIcons={nameInputIcons} | ||
disabled={!nameEditable} | ||
/> | ||
{nameEditable && ( | ||
<div className="flex cursor-pointer overflow-y-auto rounded-lg border border-panel-border bg-panel-input p-6"> | ||
<div className="shoko-scrollbar flex grow flex-col gap-y-2 overflow-y-auto bg-panel-input pr-4"> | ||
{seriesData?.map(series => ( | ||
<div | ||
className="flex justify-between last:border-none hover:text-panel-text-primary" | ||
key={series.IDs.ID} | ||
onClick={() => setGroupName(series.Name)} | ||
> | ||
<div>{series.Name}</div> | ||
</div> | ||
))} | ||
</div> | ||
</div> | ||
)} | ||
</div> | ||
); | ||
}); | ||
|
||
export default NameTab; |
124 changes: 124 additions & 0 deletions
124
src/components/Collection/Group/EditGroupTabs/SeriesTab.tsx
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,124 @@ | ||
import React, { useMemo } from 'react'; | ||
import type { PlacesType } from 'react-tooltip'; | ||
import { mdiArrowRightThinCircleOutline, mdiLoading, mdiStarCircleOutline } from '@mdi/js'; | ||
import Icon from '@mdi/react'; | ||
import cx from 'classnames'; | ||
|
||
import { useCreateGroupMutation, usePatchGroupMutation } from '@/core/react-query/group/mutations'; | ||
import { useGroupQuery, useGroupSeriesQuery } from '@/core/react-query/group/queries'; | ||
import { invalidateQueries } from '@/core/react-query/queryClient'; | ||
import useEventCallback from '@/hooks/useEventCallback'; | ||
|
||
type Props = { | ||
groupId: number; | ||
}; | ||
|
||
const SeriesTab = React.memo(({ groupId }: Props) => { | ||
const { | ||
data: groupData, | ||
isError: groupError, | ||
isPending: groupPending, | ||
isSuccess: groupSuccess, | ||
} = useGroupQuery(groupId); | ||
const { | ||
data: seriesData, | ||
isError: seriesError, | ||
isPending: seriesPending, | ||
isSuccess: seriesSuccess, | ||
} = useGroupSeriesQuery(groupId); | ||
|
||
const sortedSeriesData = useMemo(() => seriesData?.sort((a, b) => (a.IDs.ID > b.IDs.ID ? 1 : -1)), [seriesData]); | ||
|
||
const { mutate: moveToNewGroupMutation } = useCreateGroupMutation(); | ||
const { mutate: setGroupMainSeriesMutation } = usePatchGroupMutation(); | ||
|
||
const moveSeriesToNewGroup = useEventCallback((seriesId: number) => { | ||
moveToNewGroupMutation(seriesId, { | ||
onSuccess: () => { | ||
invalidateQueries(['group', groupId]); | ||
invalidateQueries(['group-series', groupId]); | ||
}, | ||
}); | ||
}); | ||
|
||
const setMainSeries = useEventCallback((seriesId: number) => { | ||
if (groupData!.IDs.MainSeries !== seriesId) { | ||
setGroupMainSeriesMutation({ | ||
groupId, | ||
seriesId, | ||
operations: [{ op: 'replace', path: 'PreferredSeriesID', value: seriesId }], | ||
}, { | ||
onSuccess: () => { | ||
invalidateQueries(['group', groupId]); | ||
invalidateQueries(['group-series', groupId]); | ||
}, | ||
}); | ||
} | ||
}); | ||
|
||
return ( | ||
<div className="flex h-full flex-col"> | ||
<div className="flex overflow-y-auto rounded-lg border border-panel-border bg-panel-input p-6"> | ||
<div | ||
className={cx( | ||
'shoko-scrollbar flex grow flex-col gap-y-2 overflow-y-auto bg-panel-input', | ||
(seriesSuccess && seriesData.length > 9) && 'pr-4', | ||
)} | ||
> | ||
{(seriesPending || groupPending) && ( | ||
<Icon | ||
path={mdiLoading} | ||
size={3} | ||
className="my-auto self-center text-panel-text-primary" | ||
spin | ||
/> | ||
)} | ||
{(seriesError || groupError) && ( | ||
<span className="my-auto self-center text-panel-text-danger">Error, please refresh!</span> | ||
)} | ||
{(seriesSuccess && groupSuccess) && sortedSeriesData?.map((series) => { | ||
const isMainSeries = series.IDs.ID === groupData?.IDs.MainSeries; | ||
|
||
const baseTooltipAttribs = { | ||
'data-tooltip-id': 'tooltip', | ||
'data-tooltip-place': 'top' as PlacesType, | ||
'data-tooltip-delay-show': 500, | ||
}; | ||
const mainSeriesTooltip = isMainSeries | ||
? {} | ||
: { ...baseTooltipAttribs, 'data-tooltip-content': 'Set as main series' }; | ||
const moveToNewGroupTooltip = { ...baseTooltipAttribs, 'data-tooltip-content': 'Move series to new group' }; | ||
|
||
return ( | ||
<div | ||
className="flex justify-between gap-x-2 last:border-none hover:text-panel-text-primary" | ||
key={series.IDs.ID} | ||
> | ||
<div className="grow select-none">{series.Name}</div> | ||
<div | ||
className={cx( | ||
'shrink-0', | ||
isMainSeries ? 'text-panel-icon-warning opacity-65' : 'text-panel-icon-action cursor-pointer', | ||
)} | ||
{...mainSeriesTooltip} | ||
onClick={() => setMainSeries(series.IDs.ID)} | ||
> | ||
<Icon path={mdiStarCircleOutline} size={1} /> | ||
</div> | ||
<div | ||
className="shrink-0 cursor-pointer text-panel-icon-action" | ||
{...moveToNewGroupTooltip} | ||
onClick={() => moveSeriesToNewGroup(series.IDs.ID)} | ||
> | ||
<Icon path={mdiArrowRightThinCircleOutline} size={1} /> | ||
</div> | ||
</div> | ||
); | ||
})} | ||
</div> | ||
</div> | ||
</div> | ||
); | ||
}); | ||
|
||
export default SeriesTab; |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Oops, something went wrong.