Files
TriggerFrontend/src/pages/settings/components/Applications/Applications.tsx
Sean Edmunds 2f8d3ad58e
All checks were successful
Deploy to production / build (push) Successful in 18s
Added persistent sort to more locations
2026-10-20 18:58:52 -05:00

446 lines
13 KiB
TypeScript

import { DragDropContext, Draggable, Droppable } from '@hello-pangea/dnd';
import {
Box,
Button,
type ComboboxItem,
type ComboboxLikeRenderOptionInput,
Grid,
GridCol,
Group,
LoadingOverlay,
Popover,
Select,
Stack,
Text,
TextInput,
} from '@mantine/core';
import { isNotEmpty, useForm } from '@mantine/form';
import { useDebouncedValue, useDisclosure } from '@mantine/hooks';
import { AddItemModal, AppItem, NoItemsFound, SettingsHeader } from 'components';
import { useCommonHooks, useMutationAPI, useQueryAPI } from 'hooks';
import { useEffect, useState } from 'react';
import * as fa6 from 'react-icons/fa6';
import * as si from 'react-icons/si';
import * as vsc from 'react-icons/vsc';
import { buttonClasses, popoverClasses, selectClasses, textInputClasses } from 'styles';
import type { Application } from 'types';
import DebouncedApplication from './DebouncedApplication';
export function Applications() {
const allIcons: any = { ...fa6, ...vsc, ...si };
const { isMobile, reorder, theme, settings, updateSetting } = useCommonHooks();
const defaultApplication: Application = {
id: null,
name: '',
url: '',
icon: '',
baseUrl: '',
filterName: 'Default',
index: 0,
enabled: 1,
};
const [dragging, setDragging] = useState(false);
const [searchValue, setSearchValue] = useState<string | number>('');
const [debouncedSearch] = useDebouncedValue(searchValue, 500);
const form = useForm({
initialValues: defaultApplication,
validate: {
name: isNotEmpty('Name is required'),
url: isNotEmpty('URL is required'),
icon: isNotEmpty('Icon is required'),
filterName: isNotEmpty('Filter name is required'),
},
});
const appsEndpoint = 'api/applications';
const appsOrderEndpoint = 'api/applicationOrder';
const { data: applicationData, isFetching: applicationDataIsFetching } = useQueryAPI({
endpoint: appsEndpoint,
});
const applications: Application[] = applicationData
?.filter(
(a: Application) =>
a.name.toLowerCase().includes((debouncedSearch as string).toLowerCase()) ||
a.url.toLowerCase().includes((debouncedSearch as string).toLowerCase()) ||
a.baseUrl.toLowerCase().includes((debouncedSearch as string).toLowerCase()),
)
.sort((a: Application, b: Application) => {
if (!dragging) {
switch (settings.appSettingsSort) {
case 'A>Z':
return a.name.toLocaleLowerCase() > b.name.toLocaleLowerCase() ? 1 : -1;
case 'Z>A':
return a.name.toLocaleLowerCase() < b.name.toLocaleLowerCase() ? 1 : -1;
case 'Newest':
if (a.id && b.id) {
return a.id < b.id ? 1 : -1;
}
return -1;
case 'Oldest':
if (a.id && b.id) {
return a.id > b.id ? 1 : -1;
}
return -1;
case 'Custom':
return a.index > b.index ? 1 : -1;
default:
return 0;
}
}
return a.index > b.index ? 1 : -1;
});
const [applicationList, setApplicationList] = useState<Application[]>([]);
const { mutateAsync } = useMutationAPI();
const [isOpen, setIsOpen] = useState(false);
const [isLoading, setIsLoading] = useState(false);
function onDragEnd(result: any) {
if (!result.destination) {
return;
}
if (result.destination.index === result.source.index) {
return;
}
const items = reorder(applications, result.source.index, result.destination.index);
setApplicationList(
items.map((item: any, index: number) => {
return {
...item,
index: index,
};
}),
);
}
const [modalOpened, { open: openModal, close: closeModal }] = useDisclosure(false);
const handleClose = () => {
closeModal();
form.reset();
};
useEffect(() => {
if (debouncedSearch === searchValue) {
setIsLoading(false);
}
}, [debouncedSearch]);
useEffect(() => {
if (applicationList.length > 0 && applicationList !== applications) {
mutateAsync({
method: 'PATCH',
endpoint: appsOrderEndpoint,
body: applicationList,
}).catch((error) => {
console.error(error);
});
}
}, [applicationList]);
return (
<Stack>
<SettingsHeader
setDragging={applications?.length > 0 ? setDragging : undefined}
openModal={openModal}
title={'Applications'}
tooltip={{
title:
'Applications are the app buttons that will be displayed on the homepage.',
highlightList: [
'Applications',
'applicationData',
'application',
'app',
'apps',
'name',
'base url',
'url',
'icon',
'filter',
],
description:
'Applications contain a name, base url, url, icon and filter. URLs can contain either http(s):// or not. Base URLs should start with /, otherwise they will cause errors.\n\nApplications that have been disabled will display a disabled icon on top of them.',
}}
searchValue={searchValue}
setSearchValue={setSearchValue}
searchPlaceholder={'Search Applications...'}
isLoading={isLoading}
setIsLoading={setIsLoading}
sortBy={settings.appSettingsSort}
setSortBy={(value) => {
updateSetting('appSettingsSort', value);
}}
customSearchEnabled
/>
{modalOpened && (
<AddItemModal
modalOpened={modalOpened}
handleClose={handleClose}
title={'Add a new application'}
body={
<form
onSubmit={form.onSubmit((values) => {
void mutateAsync({
method: 'POST',
endpoint: appsEndpoint,
body: {
...values,
index: applications?.length,
},
});
handleClose();
})}
>
<Stack gap={0} align='center'>
<Group w={'100%'} justify='center'>
<TextInput
key={form.key('name')}
{...form.getInputProps('name')}
label={'Name'}
placeholder='eg. Nextcloud'
className={textInputClasses.settingsItem}
w={'45%'}
classNames={textInputClasses}
withAsterisk
styles={
theme.textShadowsEnabled
? {
label: { boxShadow: '2px 2px var(--mantine-color-border-0)' },
}
: undefined
}
/>
<TextInput
key={form.key('baseUrl')}
{...form.getInputProps('baseUrl')}
label={'Base URL'}
placeholder='eg. /nextcloud'
className={textInputClasses.settingsItem}
w={'45%'}
classNames={textInputClasses}
styles={
theme.textShadowsEnabled
? {
label: { boxShadow: '2px 2px var(--mantine-color-border-0)' },
}
: undefined
}
/>
</Group>
<Group w={isMobile ? '94%' : '92%'} justify='center'>
<TextInput
key={form.key('url')}
{...form.getInputProps('url')}
label={'URL'}
placeholder='eg. https://domain.example.com/nextcloud'
className={textInputClasses.settingsItem}
w={'100%'}
classNames={textInputClasses}
withAsterisk
styles={
theme.textShadowsEnabled
? {
label: { boxShadow: '2px 2px var(--mantine-color-border-0)' },
}
: undefined
}
/>
</Group>
<Group w={'100%'} justify='center' pos={'relative'}>
<Select
key={form.key('icon')}
{...form.getInputProps('icon')}
label={'Icon'}
className={selectClasses.settingsItem}
classNames={{
wrapper: selectClasses.menuSelectWrapper,
input: selectClasses.menuSelectInput,
label: selectClasses.menuSelectLabel,
options: selectClasses.menuSelectOptions,
option: selectClasses.menuSelectOption,
dropdown: selectClasses.dropdown,
}}
w={'45%'}
mt={'.75rem'}
limit={100}
placeholder='Search for an icon...'
data={Object.keys(allIcons).map((icon) => {
return { value: icon, label: icon };
})}
comboboxProps={{
zIndex: 3000,
}}
styles={
theme.textShadowsEnabled
? {
label: { boxShadow: '2px 2px var(--mantine-color-border-0)' },
}
: undefined
}
renderOption={(item: ComboboxLikeRenderOptionInput<ComboboxItem>) => {
const Icon = allIcons[item.option.value];
return (
<Group>
{item.option.value}
<Icon />
</Group>
);
}}
searchable
maxDropdownHeight={175}
onDropdownOpen={() => setIsOpen(true)}
onDropdownClose={() => setIsOpen(false)}
withAsterisk
/>
<TextInput
key={form.key('filter')}
{...form.getInputProps('filter')}
label={'Filter'}
placeholder='eg. Storage'
className={textInputClasses.settingsItem}
pt={'1rem'}
w={'45%'}
classNames={textInputClasses}
defaultValue={defaultApplication.filterName}
withAsterisk
styles={
theme.textShadowsEnabled
? {
label: { boxShadow: '2px 2px var(--mantine-color-border-0)' },
}
: undefined
}
/>
</Group>
<Group mt={'1rem'}>
<Button
type='submit'
className={buttonClasses.saveButton}
classNames={buttonClasses}
>
Save
</Button>
<Button
className={buttonClasses.cancelButton}
classNames={buttonClasses}
onClick={handleClose}
>
Cancel
</Button>
</Group>
</Stack>
</form>
}
/>
)}
<DragDropContext onDragEnd={onDragEnd}>
<Stack
mt={isMobile ? '10rem' : '6rem'}
mb={isMobile ? '6rem' : '1rem'}
align='center'
>
<LoadingOverlay
visible={applicationDataIsFetching}
zIndex={180}
overlayProps={{ blur: 5 }}
loaderProps={{
color: 'var(--mantine-color-primary-0)',
type: 'bars',
size: 'xl',
}}
/>
{applications?.length > 0 ? (
<Droppable droppableId={'groupList'}>
{(provided) => (
<Grid
columns={18}
w={dragging ? '30%' : '75%'}
ref={provided.innerRef}
{...provided.droppableProps}
>
{applications?.map((application: Application, index: number) => (
<GridCol span={dragging ? 18 : 6} key={application.id}>
{dragging ? (
<Draggable
key={application.id}
draggableId={`${application.id}`}
index={Number(application.index)}
>
{(provided) => (
<Box
ref={provided.innerRef}
{...provided.draggableProps}
{...provided.dragHandleProps}
>
<Box pos={'relative'} h={'100%'}>
<Text
style={{
color: 'var(--mantine-color-primary-0)',
zIndex: 1,
position: 'absolute',
right: 4,
bottom: 0,
}}
>
#{index + 1}
</Text>
<AppItem
key={application.name}
name={application.name}
url={application.url}
icon={application.icon}
enabled={application.enabled}
scale={2}
linksActive={false}
/>
</Box>
</Box>
)}
</Draggable>
) : (
<Popover
withArrow
withOverlay
trapFocus
closeOnClickOutside={!isOpen}
classNames={popoverClasses}
middlewares={{ shift: { padding: 20 } }}
position='bottom'
offset={isMobile ? -100 : 0}
>
<Popover.Target>
<Box>
<AppItem
key={application.name}
name={application.name}
url={application.url}
icon={application.icon}
enabled={application.enabled}
scale={2}
linksActive={false}
/>
</Box>
</Popover.Target>
<Popover.Dropdown w={isMobile ? '90%' : undefined}>
<DebouncedApplication
application={application}
setIsOpen={setIsOpen}
allIcons={allIcons}
/>
</Popover.Dropdown>
</Popover>
)}
</GridCol>
))}
{provided.placeholder}
</Grid>
)}
</Droppable>
) : (
<NoItemsFound />
)}
</Stack>
</DragDropContext>
</Stack>
);
}