[ENG-1023] Change location path from settings page (#1301)

* update location path in db

* remove + add location watcher if path is changed

---------

Co-authored-by: Ericson "Fogo" Soares <ericson.ds999@gmail.com>
This commit is contained in:
Brendan Allan 2023-09-08 19:46:16 +08:00 committed by GitHub
parent 99ccb8f8c7
commit 4b60ff2e08
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
13 changed files with 106 additions and 90 deletions

View file

@ -21,6 +21,7 @@ import { type SettingsStackScreenProps } from '~/navigation/SettingsNavigator';
const schema = z.object({ const schema = z.object({
displayName: z.string().nullable(), displayName: z.string().nullable(),
path: z.string().min(1).nullable(),
localPath: z.string().nullable(), localPath: z.string().nullable(),
indexer_rules_ids: z.array(z.string()), indexer_rules_ids: z.array(z.string()),
generatePreviewMedia: z.boolean().nullable(), generatePreviewMedia: z.boolean().nullable(),
@ -51,6 +52,7 @@ const EditLocationSettingsScreen = ({
updateLocation.mutateAsync({ updateLocation.mutateAsync({
id: Number(id), id: Number(id),
name: data.displayName, name: data.displayName,
path: data.path,
sync_preview_media: data.syncPreviewMedia, sync_preview_media: data.syncPreviewMedia,
generate_preview_media: data.generatePreviewMedia, generate_preview_media: data.generatePreviewMedia,
hidden: data.hidden, hidden: data.hidden,

View file

@ -182,8 +182,8 @@ pub(crate) fn mount() -> AlphaRouter<Ctx> {
}) })
.procedure("update", { .procedure("update", {
R.with2(library()) R.with2(library())
.mutation(|(_, library), args: LocationUpdateArgs| async move { .mutation(|(node, library), args: LocationUpdateArgs| async move {
let ret = args.update(&library).await.map_err(Into::into); let ret = args.update(&node, &library).await.map_err(Into::into);
invalidate_query!(library, "locations.list"); invalidate_query!(library, "locations.list");
ret ret
}) })

View file

@ -256,16 +256,17 @@ impl LocationCreateArgs {
/// Old rules that aren't in this vector will be purged. /// Old rules that aren't in this vector will be purged.
#[derive(Type, Deserialize)] #[derive(Type, Deserialize)]
pub struct LocationUpdateArgs { pub struct LocationUpdateArgs {
pub id: location::id::Type, id: location::id::Type,
pub name: Option<String>, name: Option<String>,
pub generate_preview_media: Option<bool>, generate_preview_media: Option<bool>,
pub sync_preview_media: Option<bool>, sync_preview_media: Option<bool>,
pub hidden: Option<bool>, hidden: Option<bool>,
pub indexer_rules_ids: Vec<i32>, indexer_rules_ids: Vec<i32>,
path: Option<String>,
} }
impl LocationUpdateArgs { impl LocationUpdateArgs {
pub async fn update(self, library: &Arc<Library>) -> Result<(), LocationError> { pub async fn update(self, node: &Node, library: &Arc<Library>) -> Result<(), LocationError> {
let Library { sync, db, .. } = &**library; let Library { sync, db, .. } = &**library;
let location = find_location(library, self.id) let location = find_location(library, self.id)
@ -274,9 +275,10 @@ impl LocationUpdateArgs {
.await? .await?
.ok_or(LocationError::IdNotFound(self.id))?; .ok_or(LocationError::IdNotFound(self.id))?;
let name = self.name.clone();
let (sync_params, db_params): (Vec<_>, Vec<_>) = [ let (sync_params, db_params): (Vec<_>, Vec<_>) = [
self.name self.name
.clone()
.filter(|name| location.name.as_ref() != Some(name)) .filter(|name| location.name.as_ref() != Some(name))
.map(|v| { .map(|v| {
( (
@ -302,6 +304,12 @@ impl LocationUpdateArgs {
location::hidden::set(Some(v)), location::hidden::set(Some(v)),
) )
}), }),
self.path.clone().map(|v| {
(
(location::path::NAME, json!(v)),
location::path::set(Some(v)),
)
}),
] ]
.into_iter() .into_iter()
.flatten() .flatten()
@ -336,11 +344,16 @@ impl LocationUpdateArgs {
SpacedriveLocationMetadataFile::try_load(path).await? SpacedriveLocationMetadataFile::try_load(path).await?
{ {
metadata metadata
.update(library.id, maybe_missing(self.name, "location.name")?) .update(library.id, maybe_missing(name, "location.name")?)
.await?; .await?;
} }
} }
} }
if self.path.is_some() {
node.locations.remove(self.id, library.clone()).await?;
node.locations.add(self.id, library.clone()).await?;
}
} }
let current_rules_ids = location let current_rules_ids = location

View file

@ -278,6 +278,7 @@ export const RenameLocationTextBox = (props: Omit<Props, 'renameHandler'>) => {
try { try {
await renameLocation.mutateAsync({ await renameLocation.mutateAsync({
id: props.locationId, id: props.locationId,
path: null,
name: newName, name: newName,
generate_preview_media: null, generate_preview_media: null,
sync_preview_media: null, sync_preview_media: null,

View file

@ -1,11 +1,9 @@
import { Pencil, Plus, Trash } from 'phosphor-react'; import { Pencil, Plus, Trash } from 'phosphor-react';
import { useNavigate } from 'react-router'; import { useNavigate } from 'react-router';
import { ContextMenu as CM, dialogManager, toast } from '@sd/ui'; import { ContextMenu as CM, dialogManager, toast } from '@sd/ui';
import { import { AddLocationDialog } from '~/app/$libraryId/settings/library/locations/AddLocationDialog';
AddLocationDialog,
openDirectoryPickerDialog
} from '~/app/$libraryId/settings/library/locations/AddLocationDialog';
import DeleteDialog from '~/app/$libraryId/settings/library/locations/DeleteDialog'; import DeleteDialog from '~/app/$libraryId/settings/library/locations/DeleteDialog';
import { openDirectoryPickerDialog } from '~/app/$libraryId/settings/library/locations/openDirectoryPickerDialog';
import { usePlatform } from '~/util/Platform'; import { usePlatform } from '~/util/Platform';
interface Props { interface Props {

View file

@ -1,6 +1,6 @@
import { useQueryClient } from '@tanstack/react-query'; import { useQueryClient } from '@tanstack/react-query';
import { Archive, ArrowsClockwise, Info, Trash } from 'phosphor-react'; import { Archive, ArrowsClockwise, Info, Trash } from 'phosphor-react';
import { Suspense } from 'react'; import { Suspense, useEffect } from 'react';
import { Controller } from 'react-hook-form'; import { Controller } from 'react-hook-form';
import { useLibraryMutation, useLibraryQuery, useZodForm } from '@sd/client'; import { useLibraryMutation, useLibraryQuery, useZodForm } from '@sd/client';
import { import {
@ -21,13 +21,14 @@ import ModalLayout from '~/app/$libraryId/settings/ModalLayout';
import { LocationIdParamsSchema } from '~/app/route-schemas'; import { LocationIdParamsSchema } from '~/app/route-schemas';
import { useZodRouteParams } from '~/hooks'; import { useZodRouteParams } from '~/hooks';
import IndexerRuleEditor from './IndexerRuleEditor'; import IndexerRuleEditor from './IndexerRuleEditor';
import { LocationPathInputField } from './PathInput';
const FlexCol = tw.label`flex flex-col flex-1`; const FlexCol = tw.label`flex flex-col flex-1`;
const ToggleSection = tw.label`flex flex-row w-full`; const ToggleSection = tw.label`flex flex-row w-full`;
const schema = z.object({ const schema = z.object({
name: z.string().nullable(), name: z.string().nullable(),
path: z.string().nullable(), path: z.string().min(1).nullable(),
hidden: z.boolean().nullable(), hidden: z.boolean().nullable(),
indexerRulesIds: z.array(z.number()), indexerRulesIds: z.array(z.number()),
locationType: z.string(), locationType: z.string(),
@ -51,23 +52,6 @@ const EditLocationForm = () => {
const locationData = useLibraryQuery(['locations.getWithRules', locationId], { const locationData = useLibraryQuery(['locations.getWithRules', locationId], {
suspense: true suspense: true
// onSettled: (data, error) => {
// if (isFirstLoad) {
// // @ts-expect-error // TODO: Fix the types
// 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'
// });
// }
// }
}); });
const form = useZodForm({ const form = useZodForm({
@ -94,17 +78,15 @@ const EditLocationForm = () => {
} }
}); });
const { isDirty } = form.formState; const onSubmit = form.handleSubmit((data) =>
const onSubmit = form.handleSubmit(
({ name, hidden, indexerRulesIds, syncPreviewMedia, generatePreviewMedia }) =>
updateLocation.mutateAsync({ updateLocation.mutateAsync({
id: locationId, id: locationId,
name, path: data.path,
hidden, name: data.name,
indexer_rules_ids: indexerRulesIds, hidden: data.hidden,
sync_preview_media: syncPreviewMedia, indexer_rules_ids: data.indexerRulesIds,
generate_preview_media: generatePreviewMedia sync_preview_media: data.syncPreviewMedia,
generate_preview_media: data.generatePreviewMedia
}) })
); );
@ -114,15 +96,15 @@ const EditLocationForm = () => {
title="Edit Location" title="Edit Location"
topRight={ topRight={
<div className="flex flex-row space-x-3"> <div className="flex flex-row space-x-3">
{isDirty && ( {form.formState.isDirty && (
<Button onClick={() => form.reset()} variant="outline" size="sm"> <Button onClick={() => form.reset()} variant="outline" size="sm">
Reset Reset
</Button> </Button>
)} )}
<Button <Button
type="submit" type="submit"
disabled={!isDirty || form.formState.isSubmitting} disabled={!form.formState.isDirty || form.formState.isSubmitting}
variant={isDirty ? 'accent' : 'outline'} variant={form.formState.isDirty ? 'accent' : 'outline'}
size="sm" size="sm"
> >
Save Changes Save Changes
@ -139,12 +121,7 @@ const EditLocationForm = () => {
</InfoText> </InfoText>
</FlexCol> </FlexCol>
<FlexCol> <FlexCol>
<InputField <LocationPathInputField label="Path" {...form.register('path')} />
label="Local Path"
readOnly={true}
className="text-ink-dull"
{...form.register('path')}
/>
<InfoText className="mt-2"> <InfoText className="mt-2">
The path to this Location, this is where the files will be stored on The path to this Location, this is where the files will be stored on
disk. disk.

View file

@ -5,7 +5,8 @@ import { useRef, useState } from 'react';
import { Button, type ButtonProps, dialogManager } from '@sd/ui'; import { Button, type ButtonProps, dialogManager } from '@sd/ui';
import { useCallbackToWatchResize } from '~/hooks'; import { useCallbackToWatchResize } from '~/hooks';
import { usePlatform } from '~/util/Platform'; import { usePlatform } from '~/util/Platform';
import { AddLocationDialog, openDirectoryPickerDialog } from './AddLocationDialog'; import { AddLocationDialog } from './AddLocationDialog';
import { openDirectoryPickerDialog } from './openDirectoryPickerDialog';
interface AddLocationButton extends ButtonProps { interface AddLocationButton extends ButtonProps {
path?: string; path?: string;

View file

@ -1,6 +1,4 @@
import clsx from 'clsx'; import { useCallback, useEffect, useMemo } from 'react';
import { CaretDown } from 'phosphor-react';
import { useCallback, useEffect, useMemo, useState } from 'react';
import { Controller, get } from 'react-hook-form'; import { Controller, get } from 'react-hook-form';
import { useDebouncedCallback } from 'use-debounce'; import { useDebouncedCallback } from 'use-debounce';
import { import {
@ -11,11 +9,12 @@ import {
usePlausibleEvent, usePlausibleEvent,
useZodForm useZodForm
} from '@sd/client'; } from '@sd/client';
import { Dialog, ErrorMessage, InputField, UseDialogProps, toast, useDialog, z } from '@sd/ui'; import { Dialog, ErrorMessage, UseDialogProps, toast, useDialog, z } from '@sd/ui';
import Accordion from '~/components/Accordion'; import Accordion from '~/components/Accordion';
import { useCallbackToWatchForm } from '~/hooks'; import { useCallbackToWatchForm } from '~/hooks';
import { Platform, usePlatform } from '~/util/Platform'; import { usePlatform } from '~/util/Platform';
import IndexerRuleEditor from './IndexerRuleEditor'; import IndexerRuleEditor from './IndexerRuleEditor';
import { LocationPathInputField } from './PathInput';
const REMOTE_ERROR_FORM_FIELD = 'root.serverError'; const REMOTE_ERROR_FORM_FIELD = 'root.serverError';
const REMOTE_ERROR_FORM_MESSAGE = { const REMOTE_ERROR_FORM_MESSAGE = {
@ -39,18 +38,6 @@ const schema = z.object({
type SchemaType = z.infer<typeof schema>; type SchemaType = z.infer<typeof schema>;
export const openDirectoryPickerDialog = async (platform: Platform): Promise<null | string> => {
if (!platform.openDirectoryPickerDialog) return null;
const path = await platform.openDirectoryPickerDialog();
if (!path) return '';
if (typeof path !== 'string')
// TODO: Should adding multiple locations simultaneously be implemented?
throw new Error('Adding multiple locations simultaneously is not supported');
return path;
};
export interface AddLocationDialog extends UseDialogProps { export interface AddLocationDialog extends UseDialogProps {
path: string; path: string;
method?: RemoteErrorFormMessage; method?: RemoteErrorFormMessage;
@ -129,7 +116,7 @@ export const AddLocationDialog = ({
throw new Error('Unimplemented custom remote error handling'); throw new Error('Unimplemented custom remote error handling');
} }
}, },
[createLocation, relinkLocation, addLocationToLibrary, addLocationToLibrary] [createLocation, relinkLocation, addLocationToLibrary, submitPlausibleEvent]
); );
const handleAddError = useCallback( const handleAddError = useCallback(
@ -218,18 +205,7 @@ export const AddLocationDialog = ({
> >
<ErrorMessage name={REMOTE_ERROR_FORM_FIELD} variant="large" className="mb-4 mt-2" /> <ErrorMessage name={REMOTE_ERROR_FORM_FIELD} variant="large" className="mb-4 mt-2" />
<InputField <LocationPathInputField {...form.register('path')} />
size="md"
label="Path:"
onClick={() =>
openDirectoryPickerDialog(platform)
.then((path) => path && form.setValue('path', path))
.catch((error) => toast.error(String(error)))
}
readOnly={platform.platform !== 'web'}
className={clsx('mb-3', platform.platform === 'web' || 'cursor-pointer')}
{...form.register('path')}
/>
<input type="hidden" {...form.register('method')} /> <input type="hidden" {...form.register('method')} />

View file

@ -3,7 +3,7 @@ import { ChangeEvent, ChangeEventHandler, forwardRef, memo } from 'react';
import { Input, toast } from '@sd/ui'; import { Input, toast } from '@sd/ui';
import { useOperatingSystem } from '~/hooks'; import { useOperatingSystem } from '~/hooks';
import { usePlatform } from '~/util/Platform'; import { usePlatform } from '~/util/Platform';
import { openDirectoryPickerDialog } from '../AddLocationDialog'; import { openDirectoryPickerDialog } from '../openDirectoryPickerDialog';
export type InputKinds = 'Name' | 'Extension' | 'Path' | 'Advanced'; export type InputKinds = 'Name' | 'Extension' | 'Path' | 'Advanced';

View file

@ -0,0 +1,35 @@
import clsx from 'clsx';
import { forwardRef } from 'react';
import { useFormContext } from 'react-hook-form';
import { InputField, InputFieldProps, toast } from '@sd/ui';
import { usePlatform } from '~/util/Platform';
import { openDirectoryPickerDialog } from './openDirectoryPickerDialog';
export const LocationPathInputField = forwardRef<
HTMLInputElement,
Omit<InputFieldProps, 'onClick' | 'readOnly' | 'className'>
>((props, ref) => {
const platform = usePlatform();
const form = useFormContext();
console.log(form.formState.isDirty);
return (
<InputField
{...props}
ref={ref}
onClick={() =>
openDirectoryPickerDialog(platform)
.then(
(path) =>
path &&
form.setValue(props.name, path, {
shouldDirty: true
})
)
.catch((error) => toast.error(String(error)))
}
readOnly={platform.platform !== 'web'}
className={clsx('mb-3', platform.platform === 'web' || 'cursor-pointer')}
/>
);
});

View file

@ -0,0 +1,13 @@
import { Platform } from '~/util/Platform';
export const openDirectoryPickerDialog = async (platform: Platform): Promise<null | string> => {
if (!platform.openDirectoryPickerDialog) return null;
const path = await platform.openDirectoryPickerDialog();
if (!path) return '';
if (typeof path !== 'string')
// TODO: Should adding multiple locations simultaneously be implemented?
throw new Error('Adding multiple locations simultaneously is not supported');
return path;
};

View file

@ -267,7 +267,7 @@ export type LocationSettings = { explorer: ExplorerSettings<FilePathOrder> }
* It is important to note that only the indexer rule ids in this vector will be used from now on. * It is important to note that only the indexer rule ids in this vector will be used from now on.
* Old rules that aren't in this vector will be purged. * Old rules that aren't in this vector will be purged.
*/ */
export type LocationUpdateArgs = { id: number; name: string | null; generate_preview_media: boolean | null; sync_preview_media: boolean | null; hidden: boolean | null; indexer_rules_ids: number[] } export type LocationUpdateArgs = { id: number; name: string | null; generate_preview_media: boolean | null; sync_preview_media: boolean | null; hidden: boolean | null; indexer_rules_ids: number[]; path: string | null }
export type LocationWithIndexerRules = { id: number; pub_id: number[]; name: string | null; path: string | null; total_capacity: number | null; available_capacity: number | null; is_archived: boolean | null; generate_preview_media: boolean | null; sync_preview_media: boolean | null; hidden: boolean | null; date_created: string | null; instance_id: number | null; indexer_rules: { indexer_rule: IndexerRule }[] } export type LocationWithIndexerRules = { id: number; pub_id: number[]; name: string | null; path: string | null; total_capacity: number | null; available_capacity: number | null; is_archived: boolean | null; generate_preview_media: boolean | null; sync_preview_media: boolean | null; hidden: boolean | null; date_created: string | null; instance_id: number | null; indexer_rules: { indexer_rule: IndexerRule }[] }

View file

@ -140,11 +140,11 @@ export function Label({ slug, children, className, ...props }: LabelProps) {
); );
} }
interface PasswordInputProps extends InputProps { interface Props extends InputProps {
buttonClassnames?: string; buttonClassnames?: string;
} }
export const PasswordInput = forwardRef<HTMLInputElement, PasswordInputProps>((props, ref) => { export const PasswordInput = forwardRef<HTMLInputElement, Props>((props, ref) => {
const [showPassword, setShowPassword] = useState(false); const [showPassword, setShowPassword] = useState(false);
const CurrentEyeIcon = showPassword ? EyeSlash : Eye; const CurrentEyeIcon = showPassword ? EyeSlash : Eye;