[ENG-1210] Navigate to location after adding it (#1454)

* remove annoying log

* return location id on creation

* add checkbox to open new location once it's been added

* redirect if checkbox was true and a location id was provided, and update bindings

* add `new()` for `MissingFieldError`

* return location id on location relink

* working, clean redirecting

* accordion closing tag

* navigate to location after adding

* chore: remove erroneous `{' '}`

* multiple location redirect support

* oops missed check

* cleanup

* fix bad merge

---------

Co-authored-by: ameer2468 <33054370+ameer2468@users.noreply.github.com>
This commit is contained in:
jake 2023-10-20 22:01:13 +01:00 committed by GitHub
parent 5f417e7c1b
commit cc72f54c3d
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
11 changed files with 165 additions and 47 deletions

View file

@ -208,11 +208,13 @@ pub(crate) fn mount() -> AlphaRouter<Ctx> {
R.with2(library())
.mutation(|(node, library), args: LocationCreateArgs| async move {
if let Some(location) = args.create(&node, &library).await? {
let id = Some(location.id);
scan_location(&node, &library, location).await?;
invalidate_query!(library, "locations.list");
Ok(id)
} else {
Ok(None)
}
Ok(())
})
})
.procedure("update", {
@ -244,10 +246,13 @@ pub(crate) fn mount() -> AlphaRouter<Ctx> {
R.with2(library())
.mutation(|(node, library), args: LocationCreateArgs| async move {
if let Some(location) = args.add_library(&node, &library).await? {
let id = location.id;
scan_location(&node, &library, location).await?;
invalidate_query!(library, "locations.list");
Ok(Some(id))
} else {
Ok(None)
}
Ok(())
})
})
.procedure("fullRescan", {

View file

@ -9,7 +9,7 @@ use crate::{
},
prisma::{file_path, indexer_rules_in_location, location, PrismaClient},
util::{
db::maybe_missing,
db::{maybe_missing, MissingFieldError},
error::{FileIOError, NonUtf8PathError},
},
Node,
@ -532,7 +532,7 @@ pub async fn light_scan_location(
pub async fn relink_location(
Library { db, id, sync, .. }: &Library,
location_path: impl AsRef<Path>,
) -> Result<(), LocationError> {
) -> Result<i32, LocationError> {
let location_path = location_path.as_ref();
let mut metadata = SpacedriveLocationMetadataFile::try_load(&location_path)
.await?
@ -547,7 +547,7 @@ pub async fn relink_location(
.ok_or_else(|| NonUtf8PathError(location_path.into()))?;
sync.write_op(
db,
&db,
sync.shared_update(
prisma_sync::location::SyncId {
pub_id: pub_id.clone(),
@ -556,13 +556,23 @@ pub async fn relink_location(
json!(path),
),
db.location().update(
location::pub_id::equals(pub_id),
location::pub_id::equals(pub_id.clone()),
vec![location::path::set(Some(path))],
),
)
.await?;
Ok(())
let location_id = db
.location()
.find_unique(location::pub_id::equals(pub_id))
.select(location::select!({ id }))
.exec()
.await?
.ok_or_else(|| {
LocationError::MissingField(MissingFieldError::new("missing id of location"))
})?;
Ok(location_id.id)
}
#[derive(Debug)]

View file

@ -69,6 +69,13 @@ pub fn inode_to_db(inode: u64) -> Vec<u8> {
#[error("Missing field {0}")]
pub struct MissingFieldError(&'static str);
impl MissingFieldError {
#[must_use]
pub const fn new(value: &'static str) -> Self {
Self(value)
}
}
impl From<MissingFieldError> for rspc::Error {
fn from(value: MissingFieldError) -> Self {
rspc::Error::with_cause(

View file

@ -8,7 +8,8 @@ import {
type ExplorerItem,
type ExplorerLayout,
type ExplorerSettings,
type SortOrder
type SortOrder,
JobGroup
} from '@sd/client';
export enum ExplorerKind {
@ -121,6 +122,7 @@ const state = {
tagAssignMode: false,
showInspector: false,
showMoreInfo: false,
jobsToRedirect: [] as {locationId: number | null}[],
mediaPlayerVolume: 0.7,
newThumbnails: proxySet() as Set<string>,
cutCopyState: { type: 'Idle' } as CutCopyState,

View file

@ -1,5 +1,6 @@
import { Pencil, Plus, Trash } from '@phosphor-icons/react';
import { useNavigate } from 'react-router';
import { useLibraryContext } from '@sd/client';
import { ContextMenu as CM, dialogManager, toast } from '@sd/ui';
import { AddLocationDialog } from '~/app/$libraryId/settings/library/locations/AddLocationDialog';
import DeleteDialog from '~/app/$libraryId/settings/library/locations/DeleteDialog';
@ -14,6 +15,8 @@ interface Props {
export default ({ children, locationId }: Props) => {
const navigate = useNavigate();
const platform = usePlatform();
const libraryId = useLibraryContext().library.uuid;
return (
<CM.Root trigger={children}>
<CM.Item
@ -22,7 +25,11 @@ export default ({ children, locationId }: Props) => {
const path = await openDirectoryPickerDialog(platform);
if (path !== '') {
dialogManager.create((dp) => (
<AddLocationDialog path={path ?? ''} {...dp} />
<AddLocationDialog
path={path ?? ''}
libraryId={libraryId}
{...dp}
/>
));
}
} catch (error) {

View file

@ -2,6 +2,8 @@ import { FolderSimplePlus } from '@phosphor-icons/react';
import clsx from 'clsx';
import { motion } from 'framer-motion';
import { useRef, useState } from 'react';
import { useNavigate } from 'react-router';
import { useLibraryContext } from '@sd/client';
import { Button, dialogManager, type ButtonProps } from '@sd/ui';
import { useCallbackToWatchResize } from '~/hooks';
import { usePlatform } from '~/util/Platform';
@ -16,6 +18,9 @@ interface AddLocationButton extends ButtonProps {
export const AddLocationButton = ({ path, className, onClick, ...props }: AddLocationButton) => {
const platform = usePlatform();
const libraryId = useLibraryContext().library.uuid;
const navigate = useNavigate();
const transition = {
type: 'keyframes',
ease: 'easeInOut',
@ -49,7 +54,7 @@ export const AddLocationButton = ({ path, className, onClick, ...props }: AddLoc
// Remember `path` will be `undefined` on web cause the user has to provide it in the modal
if (path !== '')
dialogManager.create((dp) => (
<AddLocationDialog path={path ?? ''} {...dp} />
<AddLocationDialog path={path ?? ''} libraryId={libraryId} {...dp} />
));
onClick?.();

View file

@ -1,5 +1,6 @@
import { useCallback, useEffect, useMemo } from 'react';
import { Controller, get } from 'react-hook-form';
import { useNavigate } from 'react-router';
import { useDebouncedCallback } from 'use-debounce';
import {
extractInfoRSPCError,
@ -9,7 +10,8 @@ import {
usePlausibleEvent,
useZodForm
} from '@sd/client';
import { Dialog, ErrorMessage, toast, useDialog, UseDialogProps, z } from '@sd/ui';
import { CheckBox, Dialog, ErrorMessage, Label, toast, useDialog, UseDialogProps, z } from '@sd/ui';
import { getExplorerStore, useExplorerStore } from '~/app/$libraryId/Explorer/store';
import Accordion from '~/components/Accordion';
import { useCallbackToWatchForm } from '~/hooks';
import { usePlatform } from '~/util/Platform';
@ -34,13 +36,15 @@ const isRemoteErrorFormMessage = (message: unknown): message is RemoteErrorFormM
const schema = z.object({
path: z.string().min(1),
method: z.enum(Object.keys(REMOTE_ERROR_FORM_MESSAGE) as UnionToTuple<RemoteErrorFormMessage>),
indexerRulesIds: z.array(z.number())
indexerRulesIds: z.array(z.number()),
shouldRedirect: z.boolean()
});
type SchemaType = z.infer<typeof schema>;
export interface AddLocationDialog extends UseDialogProps {
path: string;
libraryId: string;
method?: RemoteErrorFormMessage;
}
@ -56,6 +60,7 @@ export const AddLocationDialog = ({
const relinkLocation = useLibraryMutation('locations.relink');
const listIndexerRules = useLibraryQuery(['locations.indexer_rules.list']);
const addLocationToLibrary = useLibraryMutation('locations.addLibrary');
const explorerStore = useExplorerStore();
// This is required because indexRules is undefined on first render
const indexerRulesIds = useMemo(
@ -65,7 +70,7 @@ export const AddLocationDialog = ({
const form = useZodForm({
schema,
defaultValues: { path, method, indexerRulesIds }
defaultValues: { path, method, indexerRulesIds, shouldRedirect: true }
});
useEffect(() => {
@ -78,10 +83,12 @@ export const AddLocationDialog = ({
}, [form, path, indexerRulesIds]);
const addLocation = useCallback(
async ({ path, method, indexerRulesIds }: SchemaType, dryRun = false) => {
async ({ path, method, indexerRulesIds, shouldRedirect }: SchemaType, dryRun = false) => {
let id = null;
switch (method) {
case 'CREATE':
await createLocation.mutateAsync({
id = await createLocation.mutateAsync({
path,
dry_run: dryRun,
indexer_rules_ids: indexerRulesIds
@ -91,7 +98,7 @@ export const AddLocationDialog = ({
break;
case 'NEED_RELINK':
if (!dryRun) await relinkLocation.mutateAsync(path);
if (!dryRun) id = await relinkLocation.mutateAsync(path);
// TODO: Update relinked location with new indexer rules, don't have a way to get location id yet though
// await updateLocation.mutateAsync({
// id: locationId,
@ -104,7 +111,7 @@ export const AddLocationDialog = ({
break;
case 'ADD_LIBRARY':
await addLocationToLibrary.mutateAsync({
id = await addLocationToLibrary.mutateAsync({
path,
dry_run: dryRun,
indexer_rules_ids: indexerRulesIds
@ -116,8 +123,14 @@ export const AddLocationDialog = ({
default:
throw new Error('Unimplemented custom remote error handling');
}
if (shouldRedirect) {
getExplorerStore().jobsToRedirect = [
{ locationId: id },
...explorerStore.jobsToRedirect
];
}
},
[createLocation, relinkLocation, addLocationToLibrary, submitPlausibleEvent]
[createLocation, relinkLocation, addLocationToLibrary, submitPlausibleEvent, explorerStore]
);
const handleAddError = useCallback(
@ -206,27 +219,38 @@ export const AddLocationDialog = ({
: ''
}
>
<ErrorMessage name={REMOTE_ERROR_FORM_FIELD} variant="large" className="mb-4 mt-2" />
<LocationPathInputField {...form.register('path')} />
<input type="hidden" {...form.register('method')} />
<Accordion title="Advanced settings">
<Controller
name="indexerRulesIds"
render={({ field }) => (
<IndexerRuleEditor
field={field}
label="File indexing rules"
className="relative flex flex-col"
rulesContainerClass="grid grid-cols-2 gap-2"
ruleButtonClass="w-full"
/>
)}
control={form.control}
<div className="flex flex-col">
<ErrorMessage
name={REMOTE_ERROR_FORM_FIELD}
variant="large"
className="mb-4 mt-2"
/>
</Accordion>
<LocationPathInputField {...form.register('path')} />
<input type="hidden" {...form.register('method')} />
<div className="mb-4 flex">
<CheckBox {...form.register('shouldRedirect')} />
<Label className="mt-[3px] font-semibold">Open new location once added</Label>
</div>
<Accordion title="Advanced settings">
<Controller
name="indexerRulesIds"
render={({ field }) => (
<IndexerRuleEditor
field={field}
label="File indexing rules"
className="relative flex flex-col"
rulesContainerClass="grid grid-cols-2 gap-2"
ruleButtonClass="w-full"
/>
)}
control={form.control}
/>
</Accordion>
</div>
</Dialog>
);
};

View file

@ -1,9 +1,9 @@
import { useEffect, useMemo } from 'react';
import { useMemo } from 'react';
import { Navigate, Outlet, useMatches, type RouteObject } from 'react-router-dom';
import { currentLibraryCache, useCachedLibraries, useInvalidateQuery } from '@sd/client';
import { Dialogs, toast, Toaster } from '@sd/ui';
import { Dialogs, Toaster } from '@sd/ui';
import { RouterErrorBoundary } from '~/ErrorFallback';
import { useKeybindHandler, useTheme } from '~/hooks';
import { useKeybindHandler, useShouldRedirect, useTheme } from '~/hooks';
import libraryRoutes from './$libraryId';
import onboardingRoutes from './onboarding';
@ -11,8 +11,6 @@ import { RootContext } from './RootContext';
import './style.scss';
import { usePlatform } from '..';
const Index = () => {
const libraries = useCachedLibraries();
@ -31,6 +29,7 @@ const Wrapper = () => {
useKeybindHandler();
useInvalidateQuery();
useTheme();
useShouldRedirect();
const rawPath = useRawRoutePath();

View file

@ -23,3 +23,4 @@ export * from './useIsTextTruncated';
export * from './useKeyMatcher';
export * from './useKeyCopyCutPaste';
export * from './useMouseNavigate';
export * from './useShouldRedirect';

View file

@ -0,0 +1,58 @@
import { getExplorerStore, useExplorerStore } from "~/app/$libraryId/Explorer/store"
import { useLibraryQuery} from '@sd/client';
import { useCallback, useEffect } from "react";
import { useNavigate } from "react-router";
import { useZodRouteParams } from "../hooks/useZodRouteParams";
import { LibraryIdParamsSchema } from "../app/route-schemas";
/**
* When a user adds a location and checks the should redirect box,
* this hook will redirect them to the location
* once the indexer has been invoked
*/
export const useShouldRedirect = () => {
const { jobsToRedirect } = useExplorerStore();
const navigate = useNavigate();
const { libraryId } = useZodRouteParams(LibraryIdParamsSchema);
const jobGroups = useLibraryQuery(['jobs.reports'], {
enabled: !!(jobsToRedirect.length > 0),
refetchOnWindowFocus: false,
});
//We loop all job groups and pull the first job that matches the location id from the job group
const pullMatchingJob = useCallback(() => {
if (jobsToRedirect.length === 0) return;
let jobFound
if (jobGroups.data) {
for (const jobGroup of jobGroups.data) {
for (const job of jobGroup.jobs) {
if (job.name === 'indexer') {
const locationId = jobsToRedirect.find((l) => l.locationId === job.metadata.location.id)?.locationId
if (job.metadata.location.id === locationId && job.completed_task_count > 0) {
jobFound = job;
break;
}
}
}
}
}
return jobFound
}, [jobGroups.data, jobsToRedirect])
//Once we have a matching job, we redirect the user to the location
useEffect(() => {
if (jobGroups.data) {
const matchingJob = pullMatchingJob();
if (matchingJob) {
const locationId = jobsToRedirect.find((l) => l.locationId === matchingJob.metadata.location.id)?.locationId
navigate(`/${libraryId}/location/${locationId}`);
getExplorerStore().jobsToRedirect = jobsToRedirect.filter((l) => l.locationId !== matchingJob.metadata.location.id);
}
}
}, [jobGroups.data, pullMatchingJob, navigate, libraryId, jobsToRedirect])
}

View file

@ -73,13 +73,13 @@ export type Procedures = {
{ key: "library.create", input: CreateLibraryArgs, result: LibraryConfigWrapped } |
{ key: "library.delete", input: string, result: null } |
{ key: "library.edit", input: EditLibraryArgs, result: null } |
{ key: "locations.addLibrary", input: LibraryArgs<LocationCreateArgs>, result: null } |
{ key: "locations.create", input: LibraryArgs<LocationCreateArgs>, result: null } |
{ key: "locations.addLibrary", input: LibraryArgs<LocationCreateArgs>, result: number | null } |
{ key: "locations.create", input: LibraryArgs<LocationCreateArgs>, result: number | null } |
{ key: "locations.delete", input: LibraryArgs<number>, result: null } |
{ key: "locations.fullRescan", input: LibraryArgs<FullRescanArgs>, result: null } |
{ key: "locations.indexer_rules.create", input: LibraryArgs<IndexerRuleCreateArgs>, result: null } |
{ key: "locations.indexer_rules.delete", input: LibraryArgs<number>, result: null } |
{ key: "locations.relink", input: LibraryArgs<string>, result: null } |
{ key: "locations.relink", input: LibraryArgs<string>, result: number } |
{ key: "locations.subPathRescan", input: LibraryArgs<RescanArgs>, result: null } |
{ key: "locations.update", input: LibraryArgs<LocationUpdateArgs>, result: null } |
{ key: "nodes.edit", input: ChangeNodeNameArgs, result: null } |