[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:
Vítor Vasconcellos 2023-04-12 03:47:51 +00:00 committed by GitHub
parent b36b4d069a
commit e03bb02c81
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
5 changed files with 153 additions and 101 deletions

View file

@ -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>

View file

@ -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>

View file

@ -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>
);
}

View file

@ -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 }]);
}
};
})

View file

@ -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>
);