onboarding UI flow

This commit is contained in:
Oscar Beaumont
2022-09-12 23:33:16 +08:00
parent bd778a4f6b
commit 1fca6fb60a
16 changed files with 107 additions and 45 deletions

View File

@@ -1,4 +1,5 @@
import { TauriTransport, createClient } from '@rspc/client';
import { createClient } from '@rspc/client';
import { TauriTransport } from '@rspc/tauri';
import { OperatingSystem, Operations, PlatformProvider, queryClient, rspc } from '@sd/client';
import SpacedriveInterface, { Platform } from '@sd/interface';
import { dialog, invoke, os } from '@tauri-apps/api';

View File

@@ -26,7 +26,7 @@ export type Operations = {
{ key: ["files.setNote", LibraryArgs<SetNoteArgs>], result: null } |
{ key: ["jobs.generateThumbsForLocation", LibraryArgs<GenerateThumbsForLocationArgs>], result: null } |
{ key: ["jobs.identifyUniqueFiles", LibraryArgs<IdentifyUniqueFilesArgs>], result: null } |
{ key: ["library.create", string], result: null } |
{ key: ["library.create", string], result: LibraryConfigWrapped } |
{ key: ["library.delete", string], result: null } |
{ key: ["library.edit", EditLibraryArgs], result: null } |
{ key: ["locations.create", LibraryArgs<LocationCreateArgs>], result: null } |

View File

@@ -26,7 +26,7 @@ export type Operations = {
{ key: ["files.setNote", LibraryArgs<SetNoteArgs>], result: null } |
{ key: ["jobs.generateThumbsForLocation", LibraryArgs<GenerateThumbsForLocationArgs>], result: null } |
{ key: ["jobs.identifyUniqueFiles", LibraryArgs<IdentifyUniqueFilesArgs>], result: null } |
{ key: ["library.create", string], result: null } |
{ key: ["library.create", string], result: LibraryConfigWrapped } |
{ key: ["library.delete", string], result: null } |
{ key: ["library.edit", EditLibraryArgs], result: null } |
{ key: ["locations.create", LibraryArgs<LocationCreateArgs>], result: null } |

View File

@@ -111,20 +111,14 @@ impl LibraryManager {
node_context,
});
// TODO: Remove this before merging PR -> Currently it exists to make the app usable
if this.libraries.read().await.len() == 0 {
this.create(LibraryConfig {
name: "My Default Library".into(),
..Default::default()
})
.await?;
}
Ok(this)
}
/// create creates a new library with the given config and mounts it into the running [LibraryManager].
pub(crate) async fn create(&self, config: LibraryConfig) -> Result<(), LibraryManagerError> {
pub(crate) async fn create(
&self,
config: LibraryConfig,
) -> Result<LibraryConfigWrapped, LibraryManagerError> {
let id = Uuid::new_v4();
LibraryConfig::save(
Path::new(&self.libraries_dir).join(format!("{id}.sdlibrary")),
@@ -135,7 +129,7 @@ impl LibraryManager {
let library = Self::load(
id,
self.libraries_dir.join(format!("{id}.db")),
config,
config.clone(),
self.node_context.clone(),
)
.await?;
@@ -143,7 +137,7 @@ impl LibraryManager {
invalidate_query!(library, "library.list");
self.libraries.write().await.push(library);
Ok(())
Ok(LibraryConfigWrapped { uuid: id, config })
}
pub(crate) async fn get_all_libraries_config(&self) -> Vec<LibraryConfigWrapped> {

View File

@@ -20,7 +20,7 @@ static MIGRATIONS_DIR: Dir = include_dir!("$CARGO_MANIFEST_DIR/prisma/migrations
#[derive(Error, Debug)]
pub enum MigrationError {
#[error("An error occurred while initialising a new database connection: {0}")]
NewClient(#[from] NewClientError),
NewClient(#[from] Box<NewClientError>),
#[error("The temporary file path for the database migrations is invalid.")]
InvalidDirectory,
#[error("An error occurred creating the temporary directory for the migrations: {0}")]
@@ -40,7 +40,9 @@ pub async fn load_and_migrate(
base_path: &Path,
db_url: &str,
) -> Result<PrismaClient, MigrationError> {
let client = prisma::new_client_with_url(db_url).await?;
let client = prisma::new_client_with_url(db_url)
.await
.map_err(|err| Box::new(err))?;
let temp_migrations_dir = base_path.join("./migrations_temp");
let migrations_directory_path = temp_migrations_dir
.to_str()
@@ -60,7 +62,7 @@ pub async fn load_and_migrate(
.extract(&temp_migrations_dir)
.map_err(MigrationError::ExtractMigrations)?;
let mut connector = match &ConnectionInfo::from_url(&db_url)? {
let mut connector = match &ConnectionInfo::from_url(db_url)? {
ConnectionInfo::Sqlite { .. } => SqlMigrationConnector::new_sqlite(),
ConnectionInfo::InMemorySqlite { .. } => unreachable!(), // This is how it is in the Prisma Rust tests
};

View File

@@ -1,4 +1,12 @@
import { useCallback, useMemo } from 'react';
import {
PropsWithChildren,
createContext,
useCallback,
useContext,
useEffect,
useMemo,
useState
} from 'react';
import { proxy, useSnapshot } from 'valtio';
import { useBridgeQuery } from '../index';
@@ -7,13 +15,36 @@ import { explorerStore } from '../stores/explorerStore';
// The name of the localStorage key for caching library data
const libraryCacheLocalStorageKey = 'sd-library-list';
type OnNoLibraryFunc = () => void | Promise<void>;
// Keep this private and use `useCurrentLibrary` hook to access or mutate it
const currentLibraryUuidStore = proxy({ id: null as string | null });
const CringeContext = createContext<{
onNoLibrary: OnNoLibraryFunc;
currentLibraryId: string | null;
setCurrentLibraryId: (v: string | null) => void;
}>(undefined!);
export const LibraryContextProvider = ({
onNoLibrary,
children
}: PropsWithChildren<{ onNoLibrary: OnNoLibraryFunc }>) => {
const [currentLibraryId, setCurrentLibraryId] = useState<string | null>(null);
return (
<CringeContext.Provider value={{ onNoLibrary, currentLibraryId, setCurrentLibraryId }}>
{children}
</CringeContext.Provider>
);
};
// this is a hook to get the current library loaded into the UI. It takes care of a bunch of invariants under the hood.
export const useCurrentLibrary = () => {
const currentLibraryUuid = useSnapshot(currentLibraryUuidStore).id;
const { data: libraries, isLoading } = useBridgeQuery(['library.get'], {
const ctx = useContext(CringeContext);
if (ctx === undefined)
throw new Error(
"The 'LibraryContextProvider' was not mounted and you attempted do use the 'useCurrentLibrary' hook. Please add the provider in your component tree."
);
const { data: libraries, isLoading } = useBridgeQuery(['library.list'], {
keepPreviousData: true,
initialData: () => {
const cachedData = localStorage.getItem(libraryCacheLocalStorageKey);
@@ -29,25 +60,29 @@ export const useCurrentLibrary = () => {
},
onSuccess: (data) => {
localStorage.setItem(libraryCacheLocalStorageKey, JSON.stringify(data));
// Redirect to the onboaording flow if the user doesn't have any libraries
if (libraries?.length === 0) {
ctx.onNoLibrary();
}
}
});
const switchLibrary = useCallback((libraryUuid: string) => {
currentLibraryUuidStore.id = libraryUuid;
ctx.setCurrentLibraryId(libraryUuid);
explorerStore.reset();
}, []);
// memorize library to avoid re-running find function
const library = useMemo(() => {
const current = libraries?.find((l: any) => l.uuid === currentLibraryUuid);
const current = libraries?.find((l: any) => l.uuid === ctx.currentLibraryId);
// switch to first library if none set
if (libraries && !current && libraries[0]?.uuid) {
switchLibrary(libraries[0]?.uuid);
}
return current;
}, [libraries, currentLibraryUuid]); // TODO: This runs when the 'libraries' change causing the whole app to re-render which is cringe.
// TODO: Redirect to onboarding flow if the user hasn't completed it. -> localStorage?
return current;
}, [libraries, ctx.currentLibraryId]); // TODO: This runs when the 'libraries' change causing the whole app to re-render which is cringe.
return {
library,

View File

@@ -1,9 +1,9 @@
import '@fontsource/inter/variable.css';
import { queryClient } from '@sd/client';
import { LibraryContextProvider, queryClient } from '@sd/client';
import { QueryClientProvider, defaultContext } from '@tanstack/react-query';
import { ReactQueryDevtools } from '@tanstack/react-query-devtools';
import { ErrorBoundary } from 'react-error-boundary';
import { MemoryRouter } from 'react-router-dom';
import { MemoryRouter, useNavigate } from 'react-router-dom';
import { AppRouter } from './AppRouter';
import { ErrorFallback } from './ErrorFallback';
@@ -18,9 +18,20 @@ export default function SpacedriveInterface() {
<ReactQueryDevtools position="bottom-right" context={defaultContext} />
)}
<MemoryRouter>
<AppRouter />
<AppRouterWrapper />
</MemoryRouter>
</QueryClientProvider>
</ErrorBoundary>
);
}
// This can't go in `<SpacedriveInterface />` cause it needs the router context but it can't go in `<AppRouter />` because that requires this context
function AppRouterWrapper() {
const navigate = useNavigate();
return (
<LibraryContextProvider onNoLibrary={() => navigate('/onboarding')}>
<AppRouter />
</LibraryContextProvider>
);
}

View File

@@ -1,3 +1,4 @@
import { useCurrentLibrary } from '@sd/client';
import clsx from 'clsx';
import { Outlet } from 'react-router-dom';
@@ -5,8 +6,14 @@ import { Sidebar } from './components/layout/Sidebar';
import { useOperatingSystem } from './hooks/useOperatingSystem';
export function AppLayout() {
const { libraries } = useCurrentLibrary();
const os = useOperatingSystem();
// This will ensure nothing is rendered while the `useCurrentLibrary` hook navigates to the onboarding page. This prevents requests with an invalid library id being sent to the backend
if (libraries?.length === 0) {
return null;
}
return (
<div
onContextMenu={(e) => {

View File

@@ -3,6 +3,7 @@ import { Route, Routes } from 'react-router-dom';
import { AppLayout } from './AppLayout';
import { NotFound } from './NotFound';
import OnboardingScreen from './components/onboarding/Onboarding';
import { useKeyboardHandler } from './hooks/useKeyboardHandler';
import { ContentScreen } from './screens/Content';
import { DebugScreen } from './screens/Debug';
@@ -41,7 +42,7 @@ export function AppRouter() {
return (
<Routes>
<Route path="onboarding" element={<p>TODO</p>} />
<Route path="onboarding" element={<OnboardingScreen />} />
<Route element={<AppLayout />}>
{/* As we are caching the libraries in localStore so this *shouldn't* result is visual problems unless something else is wrong */}
{library === undefined ? (

View File

@@ -5,7 +5,10 @@ import { PropsWithChildren, useState } from 'react';
import Dialog from '../layout/Dialog';
export default function CreateLibraryDialog({ children }: PropsWithChildren) {
export default function CreateLibraryDialog({
children,
onSubmit
}: PropsWithChildren<{ onSubmit?: () => void }>) {
const [openCreateModal, setOpenCreateModal] = useState(false);
const [newLibName, setNewLibName] = useState('');
@@ -13,9 +16,15 @@ export default function CreateLibraryDialog({ children }: PropsWithChildren) {
const { mutate: createLibrary, isLoading: createLibLoading } = useBridgeMutation(
'library.create',
{
onSuccess: () => {
queryClient.invalidateQueries(['library.get']); // TODO: Change to `library.list`
onSuccess: (library) => {
console.log('SUBMITTING');
setOpenCreateModal(false);
queryClient.setQueryData(['library.list'], (libraries: any) => [
...(libraries || []),
library
]);
if (onSubmit) onSubmit();
},
onError: (err) => {
console.error(err);

View File

@@ -15,7 +15,7 @@ import {
import { useSnapshot } from 'valtio';
const AssignTagMenuItems = (props: { objectId: number }) => {
const tags = useLibraryQuery(['tags.getAll'], { suspense: true });
const tags = useLibraryQuery(['tags.list'], { suspense: true });
const tagsForFile = useLibraryQuery(['tags.getForFile', props.objectId], { suspense: true });
const { mutate: assignTag } = useLibraryMutation('tags.assign');

View File

@@ -49,7 +49,6 @@ export default function Dialog(props: DialogProps) {
</DialogPrimitive.Close>
<Button
type="submit"
onClick={props.ctaAction}
size="sm"
loading={props.loading}
disabled={props.loading || props.submitDisabled}

View File

@@ -64,7 +64,7 @@ function LibraryScopedSection() {
const os = useOperatingSystem();
const platform = usePlatform();
const { data: locations } = useLibraryQuery(['locations.list'], { keepPreviousData: true });
const { data: tags } = useLibraryQuery(['tags.getAll'], { keepPreviousData: true });
const { data: tags } = useLibraryQuery(['tags.list'], { keepPreviousData: true });
const { mutate: createLocation } = useLibraryMutation('locations.create');
return (

View File

@@ -1,16 +1,19 @@
import { useNavigate } from 'react-router';
export function OnboardingPage() {
import { Button } from '../../../../ui/src';
import CreateLibraryDialog from '../dialog/CreateLibraryDialog';
export default function OnboardingPage() {
const navigate = useNavigate();
return (
<div>
<h1>TODO: Onboarding Flow</h1>
<div className="p-10 flex flex-col justify-center">
<h1 className="text-red-500">Welcome to Spacedrive</h1>
{/* TODO: Library create button */}
{/* <Button variant="primary" className="mt-4" onClick={() => navigate(-1)}>
Go Back
</Button> */}
<CreateLibraryDialog onSubmit={() => navigate('overview')}>
<Button variant="primary" size="sm">
Create your library
</Button>
</CreateLibraryDialog>
</div>
);
}

BIN
pnpm-lock.yaml generated
View File

Binary file not shown.