[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({
displayName: z.string().nullable(),
path: z.string().min(1).nullable(),
localPath: z.string().nullable(),
indexer_rules_ids: z.array(z.string()),
generatePreviewMedia: z.boolean().nullable(),
@ -51,6 +52,7 @@ const EditLocationSettingsScreen = ({
updateLocation.mutateAsync({
id: Number(id),
name: data.displayName,
path: data.path,
sync_preview_media: data.syncPreviewMedia,
generate_preview_media: data.generatePreviewMedia,
hidden: data.hidden,

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -1,6 +1,4 @@
import clsx from 'clsx';
import { CaretDown } from 'phosphor-react';
import { useCallback, useEffect, useMemo, useState } from 'react';
import { useCallback, useEffect, useMemo } from 'react';
import { Controller, get } from 'react-hook-form';
import { useDebouncedCallback } from 'use-debounce';
import {
@ -11,11 +9,12 @@ import {
usePlausibleEvent,
useZodForm
} 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 { useCallbackToWatchForm } from '~/hooks';
import { Platform, usePlatform } from '~/util/Platform';
import { usePlatform } from '~/util/Platform';
import IndexerRuleEditor from './IndexerRuleEditor';
import { LocationPathInputField } from './PathInput';
const REMOTE_ERROR_FORM_FIELD = 'root.serverError';
const REMOTE_ERROR_FORM_MESSAGE = {
@ -39,18 +38,6 @@ const schema = z.object({
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 {
path: string;
method?: RemoteErrorFormMessage;
@ -129,7 +116,7 @@ export const AddLocationDialog = ({
throw new Error('Unimplemented custom remote error handling');
}
},
[createLocation, relinkLocation, addLocationToLibrary, addLocationToLibrary]
[createLocation, relinkLocation, addLocationToLibrary, submitPlausibleEvent]
);
const handleAddError = useCallback(
@ -218,18 +205,7 @@ export const AddLocationDialog = ({
>
<ErrorMessage name={REMOTE_ERROR_FORM_FIELD} variant="large" className="mb-4 mt-2" />
<InputField
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')}
/>
<LocationPathInputField {...form.register('path')} />
<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 { useOperatingSystem } from '~/hooks';
import { usePlatform } from '~/util/Platform';
import { openDirectoryPickerDialog } from '../AddLocationDialog';
import { openDirectoryPickerDialog } from '../openDirectoryPickerDialog';
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.
* 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 }[] }

View file

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