mirror of
https://github.com/spacedriveapp/spacedrive
synced 2024-07-14 03:04:14 +00:00
[ENG-291] Location settings (#689)
* Implement indexer rule editing for locations - Partially implement IndexerRuleEditor (missing indexer rule creation) - Update AddLocationDialog to use IndexerRuleEditor instead of checkboxes - Enable IndexerRuleEditor on the Location settings page - Improve error handling on the Location settings page * Change location data retrieve logic on settings page - Improve error handling on settings page (don't send NaN to backend) - Add disabled prop to Form component - Wait for data before allowing edits on location settings page - Change some snake_case object properties to camelCase - Fix a small error in rspc client that transformed any falsy argument value to null * Remove console.log * Fix issue with errors only being handled during the first load of the location settings page
This commit is contained in:
parent
b36b4d069a
commit
e03bb02c81
|
@ -1,77 +1,103 @@
|
|||
import { useQueryClient } from '@tanstack/react-query';
|
||||
import { Archive, ArrowsClockwise, Info, Trash } from 'phosphor-react';
|
||||
import { useState } from 'react';
|
||||
import { Controller } from 'react-hook-form';
|
||||
import { useParams } from 'react-router';
|
||||
import { useNavigate } from 'react-router';
|
||||
import { useLibraryMutation, useLibraryQuery } from '@sd/client';
|
||||
import { Button, Divider, forms, tw } from '@sd/ui';
|
||||
import { Tooltip } from '@sd/ui';
|
||||
import { showAlertDialog } from '~/components/AlertDialog';
|
||||
import ModalLayout from '../../ModalLayout';
|
||||
import { IndexerRuleEditor } from './IndexerRuleEditor';
|
||||
|
||||
const InfoText = tw.p`mt-2 text-xs text-ink-faint`;
|
||||
const Label = tw.label`mb-1 text-sm font-medium`;
|
||||
const FlexCol = tw.label`flex flex-col flex-1`;
|
||||
const InfoText = tw.p`mt-2 text-xs text-ink-faint`;
|
||||
const ToggleSection = tw.label`flex flex-row w-full`;
|
||||
|
||||
const { Form, Input, Switch, useZodForm, z } = forms;
|
||||
|
||||
const schema = z.object({
|
||||
displayName: z.string(),
|
||||
localPath: z.string(),
|
||||
indexer_rules_ids: z.array(z.string()),
|
||||
generatePreviewMedia: z.boolean(),
|
||||
name: z.string(),
|
||||
path: z.string(),
|
||||
hidden: z.boolean(),
|
||||
indexerRulesIds: z.array(z.number()),
|
||||
syncPreviewMedia: z.boolean(),
|
||||
hidden: z.boolean()
|
||||
generatePreviewMedia: z.boolean()
|
||||
});
|
||||
|
||||
export const Component = () => {
|
||||
const queryClient = useQueryClient();
|
||||
const { id } = useParams<{
|
||||
id: string;
|
||||
}>();
|
||||
|
||||
useLibraryQuery(['locations.getById', Number(id)], {
|
||||
onSuccess: (data) => {
|
||||
if (data && !isDirty)
|
||||
form.reset({
|
||||
displayName: data.name,
|
||||
localPath: data.path,
|
||||
indexer_rules_ids: data.indexer_rules.map((i) => i.indexer_rule.id.toString()),
|
||||
generatePreviewMedia: data.generate_preview_media,
|
||||
syncPreviewMedia: data.sync_preview_media,
|
||||
hidden: data.hidden
|
||||
});
|
||||
const form = useZodForm({
|
||||
schema,
|
||||
defaultValues: {
|
||||
indexerRulesIds: []
|
||||
}
|
||||
});
|
||||
|
||||
const form = useZodForm({
|
||||
schema
|
||||
});
|
||||
|
||||
const params = useParams<{ id: string }>();
|
||||
const navigate = useNavigate();
|
||||
const fullRescan = useLibraryMutation('locations.fullRescan');
|
||||
const queryClient = useQueryClient();
|
||||
const [isFirstLoad, setIsFirstLoad] = useState<boolean>(true);
|
||||
const updateLocation = useLibraryMutation('locations.update', {
|
||||
onError: (e) => console.log({ e }),
|
||||
onError: () => {
|
||||
showAlertDialog({
|
||||
title: 'Error',
|
||||
value: 'Failed to update location settings'
|
||||
});
|
||||
},
|
||||
onSuccess: () => {
|
||||
form.reset(form.getValues());
|
||||
queryClient.invalidateQueries(['locations.list']);
|
||||
}
|
||||
});
|
||||
|
||||
const onSubmit = form.handleSubmit((data) =>
|
||||
updateLocation.mutateAsync({
|
||||
id: Number(id),
|
||||
name: data.displayName,
|
||||
sync_preview_media: data.syncPreviewMedia,
|
||||
generate_preview_media: data.generatePreviewMedia,
|
||||
hidden: data.hidden,
|
||||
indexer_rules_ids: []
|
||||
})
|
||||
const { isDirty } = form.formState;
|
||||
// Default to first location if no id is provided
|
||||
// fallback to 0 (which should always be an invalid location) when parsing fails
|
||||
const locationId = (params.id ? Number(params.id) : 1) || 0;
|
||||
useLibraryQuery(['locations.getById', locationId], {
|
||||
onSettled: (data, error: Error | null) => {
|
||||
if (isFirstLoad) {
|
||||
if (!data && error == null) error = new Error('Failed to load location settings');
|
||||
|
||||
// Return to previous page when no data is available at first load
|
||||
if (error) navigate(-1);
|
||||
else setIsFirstLoad(false);
|
||||
}
|
||||
|
||||
if (error) {
|
||||
showAlertDialog({
|
||||
title: 'Error',
|
||||
value: 'Failed to load location settings'
|
||||
});
|
||||
} else if (data && (isFirstLoad || !isDirty)) {
|
||||
form.reset({
|
||||
path: data.path,
|
||||
name: data.name,
|
||||
hidden: data.hidden,
|
||||
indexerRulesIds: data.indexer_rules.map((i) => i.indexer_rule.id),
|
||||
syncPreviewMedia: data.sync_preview_media,
|
||||
generatePreviewMedia: data.generate_preview_media
|
||||
});
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
const onSubmit = form.handleSubmit(
|
||||
({ name, hidden, indexerRulesIds, syncPreviewMedia, generatePreviewMedia }) =>
|
||||
updateLocation.mutateAsync({
|
||||
id: locationId,
|
||||
name,
|
||||
hidden,
|
||||
indexer_rules_ids: indexerRulesIds,
|
||||
sync_preview_media: syncPreviewMedia,
|
||||
generate_preview_media: generatePreviewMedia
|
||||
})
|
||||
);
|
||||
|
||||
const fullRescan = useLibraryMutation('locations.fullRescan');
|
||||
|
||||
const { isDirty } = form.formState;
|
||||
|
||||
return (
|
||||
<Form form={form} onSubmit={onSubmit} className="h-full w-full">
|
||||
<Form form={form} disabled={isFirstLoad} onSubmit={onSubmit} className="h-full w-full">
|
||||
<ModalLayout
|
||||
title="Edit Location"
|
||||
topRight={
|
||||
|
@ -94,14 +120,19 @@ export const Component = () => {
|
|||
>
|
||||
<div className="flex space-x-4">
|
||||
<FlexCol>
|
||||
<Input label="Display Name" {...form.register('displayName')} />
|
||||
<Input label="Display Name" {...form.register('name')} />
|
||||
<InfoText>
|
||||
The name of this Location, this is what will be displayed in the sidebar. Will not
|
||||
rename the actual folder on disk.
|
||||
</InfoText>
|
||||
</FlexCol>
|
||||
<FlexCol>
|
||||
<Input label="Local Path" {...form.register('localPath')} />
|
||||
<Input
|
||||
label="Local Path"
|
||||
readOnly={true}
|
||||
className="text-ink-dull"
|
||||
{...form.register('path')}
|
||||
/>
|
||||
<InfoText>
|
||||
The path to this Location, this is where the files will be stored on disk.
|
||||
</InfoText>
|
||||
|
@ -128,18 +159,22 @@ export const Component = () => {
|
|||
</ToggleSection>
|
||||
</div>
|
||||
<Divider />
|
||||
<div className="pointer-events-none flex flex-col opacity-30">
|
||||
<div className="flex flex-col">
|
||||
<Label className="grow">Indexer rules</Label>
|
||||
<InfoText className="mt-0 mb-1">
|
||||
Indexer rules allow you to specify paths to ignore using RegEx.
|
||||
</InfoText>
|
||||
<IndexerRuleEditor locationId={id!} />
|
||||
<Controller
|
||||
name="indexerRulesIds"
|
||||
render={({ field }) => <IndexerRuleEditor field={field} />}
|
||||
control={form.control}
|
||||
/>
|
||||
</div>
|
||||
<Divider />
|
||||
<div className="flex space-x-5">
|
||||
<FlexCol>
|
||||
<div>
|
||||
<Button onClick={() => fullRescan.mutate(Number(id))} size="sm" variant="outline">
|
||||
<Button onClick={() => fullRescan.mutate(locationId)} size="sm" variant="outline">
|
||||
<ArrowsClockwise className="mr-1.5 -mt-0.5 inline h-4 w-4" />
|
||||
Full Reindex
|
||||
</Button>
|
||||
|
@ -164,7 +199,7 @@ export const Component = () => {
|
|||
</FlexCol>
|
||||
<FlexCol>
|
||||
<div>
|
||||
<Button size="sm" variant="colored" className="border-red-500 bg-red-500 ">
|
||||
<Button size="sm" variant="colored" className="border-red-500 bg-red-500">
|
||||
<Trash className="mr-1.5 -mt-0.5 inline h-4 w-4" />
|
||||
Delete
|
||||
</Button>
|
||||
|
|
|
@ -3,13 +3,14 @@ import { RSPCError } from '@rspc/client';
|
|||
import { useQueryClient } from '@tanstack/react-query';
|
||||
import { useEffect, useState } from 'react';
|
||||
import { Controller } from 'react-hook-form';
|
||||
import { useLibraryMutation, useLibraryQuery } from '@sd/client';
|
||||
import { Dialog, RadixCheckbox, UseDialogProps, useDialog } from '@sd/ui';
|
||||
import { useLibraryMutation } from '@sd/client';
|
||||
import { Dialog, UseDialogProps, useDialog } from '@sd/ui';
|
||||
import { Input, useZodForm, z } from '@sd/ui/src/forms';
|
||||
import { showAlertDialog } from '~/components/AlertDialog';
|
||||
import { Platform, usePlatform } from '~/util/Platform';
|
||||
import { IndexerRuleEditor } from './IndexerRuleEditor';
|
||||
|
||||
const schema = z.object({ path: z.string(), indexer_rules_ids: z.array(z.number()) });
|
||||
const schema = z.object({ path: z.string(), indexerRulesIds: z.array(z.number()) });
|
||||
|
||||
interface Props extends UseDialogProps {
|
||||
path: string;
|
||||
|
@ -46,7 +47,6 @@ export const AddLocationDialog = (props: Props) => {
|
|||
const queryClient = useQueryClient();
|
||||
const createLocation = useLibraryMutation('locations.create');
|
||||
const relinkLocation = useLibraryMutation('locations.relink');
|
||||
const indexerRulesList = useLibraryQuery(['locations.indexer_rules.list']);
|
||||
const addLocationToLibrary = useLibraryMutation('locations.addLibrary');
|
||||
const [remoteError, setRemoteError] = useState<null | RemoteErrorFormMessage>(null);
|
||||
|
||||
|
@ -54,7 +54,7 @@ export const AddLocationDialog = (props: Props) => {
|
|||
schema,
|
||||
defaultValues: {
|
||||
path: props.path,
|
||||
indexer_rules_ids: []
|
||||
indexerRulesIds: []
|
||||
}
|
||||
});
|
||||
|
||||
|
@ -67,10 +67,10 @@ export const AddLocationDialog = (props: Props) => {
|
|||
return () => subscription.unsubscribe();
|
||||
}, [form]);
|
||||
|
||||
const onLocationSubmit = form.handleSubmit(async ({ path, indexer_rules_ids }) => {
|
||||
const onLocationSubmit = form.handleSubmit(async ({ path, indexerRulesIds }) => {
|
||||
switch (remoteError) {
|
||||
case null:
|
||||
await createLocation.mutateAsync({ path, indexer_rules_ids });
|
||||
await createLocation.mutateAsync({ path, indexer_rules_ids: indexerRulesIds });
|
||||
break;
|
||||
case 'NEED_RELINK':
|
||||
await relinkLocation.mutateAsync(path);
|
||||
|
@ -85,7 +85,7 @@ export const AddLocationDialog = (props: Props) => {
|
|||
// });
|
||||
break;
|
||||
case 'ADD_LIBRARY':
|
||||
await addLocationToLibrary.mutateAsync({ path, indexer_rules_ids });
|
||||
await addLocationToLibrary.mutateAsync({ path, indexer_rules_ids: indexerRulesIds });
|
||||
break;
|
||||
default:
|
||||
throw new Error('Unimplemented custom remote error handling');
|
||||
|
@ -160,28 +160,11 @@ export const AddLocationDialog = (props: Props) => {
|
|||
|
||||
<div className="relative flex flex-col">
|
||||
<p className="my-2 text-sm font-bold">File indexing rules:</p>
|
||||
<div className="grid w-full grid-cols-2 gap-4 text-xs font-medium">
|
||||
<div className="w-full text-xs font-medium">
|
||||
<Controller
|
||||
name="indexer_rules_ids"
|
||||
name="indexerRulesIds"
|
||||
render={({ field }) => <IndexerRuleEditor field={field} />}
|
||||
control={form.control}
|
||||
render={({ field }) => (
|
||||
<>
|
||||
{indexerRulesList.data?.map((rule) => (
|
||||
<RadixCheckbox
|
||||
key={rule.id}
|
||||
label={rule.name}
|
||||
checked={field.value.includes(rule.id)}
|
||||
onCheckedChange={(checked) =>
|
||||
field.onChange(
|
||||
checked && checked !== 'indeterminate'
|
||||
? [...field.value, rule.id]
|
||||
: field.value.filter((fieldValue) => fieldValue !== rule.id)
|
||||
)
|
||||
}
|
||||
/>
|
||||
))}
|
||||
</>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
@ -1,32 +1,64 @@
|
|||
// import { PlusSquare } from '@phosphor-icons/react';
|
||||
import clsx from 'clsx';
|
||||
import { ControllerRenderProps, FieldPath } from 'react-hook-form';
|
||||
import { useLibraryQuery } from '@sd/client';
|
||||
import { Card, tw } from '@sd/ui';
|
||||
import { Button, Card } from '@sd/ui';
|
||||
|
||||
interface Props {
|
||||
locationId: string;
|
||||
interface FormFields {
|
||||
indexerRulesIds: number[];
|
||||
}
|
||||
|
||||
export const Rule = tw.span`inline border border-transparent px-1 text-[11px] font-medium shadow shadow-app-shade/5 bg-app-selected rounded-md text-ink-dull`;
|
||||
type FieldType = ControllerRenderProps<
|
||||
FormFields,
|
||||
Exclude<FieldPath<FormFields>, `indexerRulesIds.${number}`>
|
||||
>;
|
||||
|
||||
export function IndexerRuleEditor({ locationId }: Props) {
|
||||
export interface IndexerRuleEditorProps<T extends FieldType> {
|
||||
field: T;
|
||||
editable?: boolean;
|
||||
}
|
||||
|
||||
export function IndexerRuleEditor<T extends FieldType>({
|
||||
field,
|
||||
editable
|
||||
}: IndexerRuleEditorProps<T>) {
|
||||
const listIndexerRules = useLibraryQuery(['locations.indexer_rules.list'], {});
|
||||
const currentLocationIndexerRules = useLibraryQuery(
|
||||
['locations.indexer_rules.listForLocation', Number(locationId)],
|
||||
{}
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="flex flex-col">
|
||||
{/* <Input /> */}
|
||||
{/* <Card className="flex flex-wrap mb-2 space-x-1">
|
||||
{currentLocationIndexerRules.data?.map((rule) => (
|
||||
<Rule key={rule.indexer_rule.id}>{rule.indexer_rule.name}</Rule>
|
||||
))}
|
||||
</Card> */}
|
||||
<Card className="mb-2 flex flex-wrap space-x-1">
|
||||
{listIndexerRules.data?.map((rule) => (
|
||||
<Rule key={rule.id}>{rule.name}</Rule>
|
||||
))}
|
||||
</Card>
|
||||
</div>
|
||||
<Card className="mb-2 flex flex-wrap justify-evenly">
|
||||
{listIndexerRules.data
|
||||
? listIndexerRules.data.map((rule) => {
|
||||
const { id, name } = rule;
|
||||
const enabled = field.value.includes(id);
|
||||
return (
|
||||
<Button
|
||||
key={id}
|
||||
size="sm"
|
||||
onClick={() =>
|
||||
field.onChange(
|
||||
enabled
|
||||
? field.value.filter((fieldValue) => fieldValue !== rule.id)
|
||||
: [...field.value, rule.id]
|
||||
)
|
||||
}
|
||||
variant={enabled ? 'colored' : 'outline'}
|
||||
className={clsx('m-1 flex-auto', enabled && 'border-accent bg-accent')}
|
||||
>
|
||||
{name}
|
||||
</Button>
|
||||
);
|
||||
})
|
||||
: editable || <p>No indexer rules available</p>}
|
||||
{/* {editable && (
|
||||
<Button
|
||||
size="icon"
|
||||
onClick={() => console.log('TODO')}
|
||||
variant="outline"
|
||||
className="m-1 flex-[0_0_99%] text-center leading-none"
|
||||
>
|
||||
<PlusSquare weight="light" size={18} className="inline" />
|
||||
</Button>
|
||||
)} */}
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
|
|
@ -56,13 +56,13 @@ const libraryHooks = hooks.createHooks<
|
|||
const libraryId = currentLibraryCache.id;
|
||||
if (libraryId === null)
|
||||
throw new Error('Attempted to do library operation with no library set!');
|
||||
return [keyAndInput[0], { library_id: libraryId, arg: keyAndInput[1] || null }];
|
||||
return [keyAndInput[0], { library_id: libraryId, arg: keyAndInput[1] ?? null }];
|
||||
},
|
||||
doMutation: (keyAndInput, next) => {
|
||||
const libraryId = currentLibraryCache.id;
|
||||
if (libraryId === null)
|
||||
throw new Error('Attempted to do library operation with no library set!');
|
||||
return next([keyAndInput[0], { library_id: libraryId, arg: keyAndInput[1] || null }]);
|
||||
return next([keyAndInput[0], { library_id: libraryId, arg: keyAndInput[1] ?? null }]);
|
||||
}
|
||||
};
|
||||
})
|
||||
|
|
|
@ -12,11 +12,13 @@ import { z } from 'zod';
|
|||
|
||||
export interface FormProps<T extends FieldValues> extends Omit<ComponentProps<'form'>, 'onSubmit'> {
|
||||
form: UseFormReturn<T>;
|
||||
disabled?: boolean;
|
||||
onSubmit?: ReturnType<UseFormHandleSubmit<T>>;
|
||||
}
|
||||
|
||||
export const Form = <T extends FieldValues>({
|
||||
form,
|
||||
disabled,
|
||||
onSubmit,
|
||||
children,
|
||||
...props
|
||||
|
@ -32,7 +34,7 @@ export const Form = <T extends FieldValues>({
|
|||
>
|
||||
{/* <fieldset> passes the form's 'disabled' state to all of its elements,
|
||||
allowing us to handle disabled style variants with just css */}
|
||||
<fieldset disabled={form.formState.isSubmitting}>{children}</fieldset>
|
||||
<fieldset disabled={disabled || form.formState.isSubmitting}>{children}</fieldset>
|
||||
</form>
|
||||
</FormProvider>
|
||||
);
|
||||
|
|
Loading…
Reference in a new issue