mirror of
https://github.com/spacedriveapp/spacedrive.git
synced 2026-05-07 14:53:16 -04:00
[ENG-384] Indexer rules editor (#723)
* WIP Create Indexer Rule UI + Backend api fix * Complete IndexerEditor UI for creating new rules - WIP testing to ensure all rules are parsed correctly - Add utilities to satisfy the TypeScript typechecker - Introduce a utility function to facilitate extracting information from an RSPCError - Modify AddLocationDialog to utilize the aforementioned utility function * Validation and submit logic is now functional - Reorganize UI for improved user flow - Implement validation and error messaging, replace some bare inputs with form inputs, and fix styling issues - Resolve issues with post-processing of rules during submission - Wrap editable portion of component in a `FormProvider` due to the `Form` being in a `Portal` - Add specialized `ErrorMessage` component to `@sd/ui` library - Update `AddLocationDialog` and `FormField` to utilize the new `ErrorMessage` component * Fix submit not waiting for confirmation from backend - Reset form after submit - Disable form while submiting - Update form related dependencies * Implement deleting an indexer rule - Modify indexer rule api route to disallow deleting default rules * Fix form reset on error - Minor style adjustments
This commit is contained in:
committed by
GitHub
parent
f2db45fec6
commit
31a87f6794
@@ -17,7 +17,7 @@
|
||||
},
|
||||
"dependencies": {
|
||||
"@gorhom/bottom-sheet": "^4.4.5",
|
||||
"@hookform/resolvers": "^2.9.11",
|
||||
"@hookform/resolvers": "^3.1.0",
|
||||
"@react-native-async-storage/async-storage": "~1.17.11",
|
||||
"@react-native-masked-view/masked-view": "0.2.8",
|
||||
"@react-navigation/bottom-tabs": "^6.5.7",
|
||||
@@ -43,7 +43,7 @@
|
||||
"moti": "^0.24.2",
|
||||
"phosphor-react-native": "^1.1.2",
|
||||
"react": "18.2.0",
|
||||
"react-hook-form": "^7.43.5",
|
||||
"react-hook-form": "^7.43.9",
|
||||
"react-native": "0.71.3",
|
||||
"react-native-document-picker": "^8.1.1",
|
||||
"react-native-fs": "^2.20.0",
|
||||
|
||||
@@ -269,6 +269,27 @@ fn mount_indexer_rule_routes() -> RouterBuilder {
|
||||
})
|
||||
.library_mutation("delete", |t| {
|
||||
t(|_, indexer_rule_id: i32, library| async move {
|
||||
let indexer_rule_db = library.db.indexer_rule();
|
||||
|
||||
if let Some(indexer_rule) = indexer_rule_db
|
||||
.to_owned()
|
||||
.find_unique(indexer_rule::id::equals(indexer_rule_id))
|
||||
.exec()
|
||||
.await?
|
||||
{
|
||||
if indexer_rule.default {
|
||||
return Err(rspc::Error::new(
|
||||
ErrorCode::Forbidden,
|
||||
format!("Indexer rule <id={indexer_rule_id}> can't be deleted"),
|
||||
));
|
||||
}
|
||||
} else {
|
||||
return Err(rspc::Error::new(
|
||||
ErrorCode::NotFound,
|
||||
format!("Indexer rule <id={indexer_rule_id}> not found"),
|
||||
));
|
||||
}
|
||||
|
||||
library
|
||||
.db
|
||||
.indexer_rules_in_location()
|
||||
@@ -278,9 +299,7 @@ fn mount_indexer_rule_routes() -> RouterBuilder {
|
||||
.exec()
|
||||
.await?;
|
||||
|
||||
library
|
||||
.db
|
||||
.indexer_rule()
|
||||
indexer_rule_db
|
||||
.delete(indexer_rule::id::equals(indexer_rule_id))
|
||||
.exec()
|
||||
.await?;
|
||||
|
||||
@@ -24,23 +24,22 @@ use tokio::fs;
|
||||
pub struct IndexerRuleCreateArgs {
|
||||
pub kind: RuleKind,
|
||||
pub name: String,
|
||||
pub parameters: Vec<u8>,
|
||||
pub parameters: Vec<String>,
|
||||
}
|
||||
|
||||
impl IndexerRuleCreateArgs {
|
||||
pub async fn create(self, library: &Library) -> Result<indexer_rule::Data, IndexerError> {
|
||||
let parameters = match self.kind {
|
||||
RuleKind::AcceptFilesByGlob | RuleKind::RejectFilesByGlob => rmp_serde::to_vec(
|
||||
&serde_json::from_slice::<Vec<String>>(&self.parameters)?
|
||||
&self
|
||||
.parameters
|
||||
.into_iter()
|
||||
.map(|s| Glob::new(&s))
|
||||
.map(|s| Glob::new(s.as_str()))
|
||||
.collect::<Result<Vec<Glob>, _>>()?,
|
||||
)?,
|
||||
|
||||
RuleKind::AcceptIfChildrenDirectoriesArePresent
|
||||
| RuleKind::RejectIfChildrenDirectoriesArePresent => {
|
||||
rmp_serde::to_vec(&serde_json::from_slice::<Vec<String>>(&self.parameters)?)?
|
||||
}
|
||||
| RuleKind::RejectIfChildrenDirectoriesArePresent => rmp_serde::to_vec(&self.parameters)?,
|
||||
};
|
||||
|
||||
library
|
||||
|
||||
@@ -31,6 +31,7 @@ pub async fn indexer_rules_seeder(client: &PrismaClient) -> Result<(), SeederErr
|
||||
],
|
||||
// Globset, even on Windows, requires the use of / as a separator
|
||||
// https://github.com/github/gitignore/blob/main/Global/Windows.gitignore
|
||||
// https://learn.microsoft.com/en-us/windows/win32/fileio/naming-a-file
|
||||
#[cfg(target_os = "windows")]
|
||||
vec![
|
||||
// Windows thumbnail cache files
|
||||
@@ -43,6 +44,9 @@ pub async fn indexer_rules_seeder(client: &PrismaClient) -> Result<(), SeederErr
|
||||
"**/$RECYCLE.BIN",
|
||||
// Chkdsk recovery directory
|
||||
"**/FOUND.[0-9][0-9][0-9]",
|
||||
// Reserved names
|
||||
"**/{CON,PRN,AUX,NUL,COM0,COM1,COM2,COM3,COM4,COM5,COM6,COM7,COM8,COM9,LPT0,LPT1,LPT2,LPT3,LPT4,LPT5,LPT6,LPT7,LPT8,LPT9}",
|
||||
"**/{CON,PRN,AUX,NUL,COM0,COM1,COM2,COM3,COM4,COM5,COM6,COM7,COM8,COM9,LPT0,LPT1,LPT2,LPT3,LPT4,LPT5,LPT6,LPT7,LPT8,LPT9}.*",
|
||||
// User special files
|
||||
"C:/Users/*/NTUSER.DAT*",
|
||||
"C:/Users/*/ntuser.dat*",
|
||||
|
||||
@@ -169,7 +169,7 @@ export const Component = () => {
|
||||
</InfoText>
|
||||
<Controller
|
||||
name="indexerRulesIds"
|
||||
render={({ field }) => <IndexerRuleEditor field={field} />}
|
||||
render={({ field }) => <IndexerRuleEditor field={field} editable />}
|
||||
control={form.control}
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -1,10 +1,8 @@
|
||||
import { ErrorMessage } from '@hookform/error-message';
|
||||
import { RSPCError } from '@rspc/client';
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
import { Controller } from 'react-hook-form';
|
||||
import { useLibraryMutation, useLibraryQuery } from '@sd/client';
|
||||
import { extractInfoRSPCError, useLibraryMutation, useLibraryQuery } from '@sd/client';
|
||||
import { Dialog, UseDialogProps, useDialog } from '@sd/ui';
|
||||
import { Input, useZodForm, z } from '@sd/ui/src/forms';
|
||||
import { ErrorMessage, Input, useZodForm, z } from '@sd/ui/src/forms';
|
||||
import { showAlertDialog } from '~/components/AlertDialog';
|
||||
import { Platform, usePlatform } from '~/util/Platform';
|
||||
import { IndexerRuleEditor } from './IndexerRuleEditor';
|
||||
@@ -64,7 +62,7 @@ export const AddLocationDialog = ({ path, ...dialogProps }: Props) => {
|
||||
}, [form, path, indexerRulesIds]);
|
||||
|
||||
useEffect(() => {
|
||||
// TODO: Instead of clearing the error on every change, we should just validate with backend again
|
||||
// TODO: Instead of clearing the error on every change, the backend should suport a way to validate without committing
|
||||
const subscription = form.watch(() => {
|
||||
form.clearErrors(REMOTE_ERROR_FORM_FIELD);
|
||||
setRemoteError(null);
|
||||
@@ -100,33 +98,27 @@ export const AddLocationDialog = ({ path, ...dialogProps }: Props) => {
|
||||
}
|
||||
});
|
||||
|
||||
const onLocationSubmitError = async (error: Error) => {
|
||||
if ('cause' in error && error.cause instanceof RSPCError) {
|
||||
// TODO: error.code property is not yet implemented in RSPCError
|
||||
// https://github.com/oscartbeaumont/rspc/blob/60a4fa93187c20bc5cb565cc6ee30b2f0903840e/packages/client/src/interop/error.ts#L59
|
||||
// So we grab it from the shape for now
|
||||
const { code } = error.cause.shape;
|
||||
if (code !== 500) {
|
||||
let { message } = error;
|
||||
const onLocationSubmitError = (error: Error) => {
|
||||
const rspcErrorInfo = extractInfoRSPCError(error);
|
||||
if (rspcErrorInfo && rspcErrorInfo.code !== 500) {
|
||||
let { message } = rspcErrorInfo;
|
||||
if (rspcErrorInfo.code == 409 && isRemoteErrorFormMessage(message)) {
|
||||
setRemoteError(message);
|
||||
message = REMOTE_ERROR_FORM_MESSAGES[message];
|
||||
|
||||
if (code == 409 && isRemoteErrorFormMessage(message)) {
|
||||
setRemoteError(message);
|
||||
message = REMOTE_ERROR_FORM_MESSAGES[message];
|
||||
|
||||
/**
|
||||
* TODO: On NEED_RELINK, we should query the backend for
|
||||
* the current location indexer_rules_ids, then update the checkboxes
|
||||
* accordingly. However we don't have the location id at this point.
|
||||
* Maybe backend could return the location id in the error?
|
||||
*/
|
||||
}
|
||||
|
||||
form.reset({}, { keepValues: true, keepErrors: true, keepIsValid: true });
|
||||
form.setError(REMOTE_ERROR_FORM_FIELD, { type: 'remote', message: message });
|
||||
|
||||
// Throw error to prevent dialog from closing
|
||||
throw error;
|
||||
/**
|
||||
* TODO: On NEED_RELINK, we should query the backend for
|
||||
* the current location indexer_rules_ids, then update the checkboxes
|
||||
* accordingly. However we don't have the location id at this point.
|
||||
* Maybe backend could return the location id in the error?
|
||||
*/
|
||||
}
|
||||
|
||||
form.reset({}, { keepValues: true, keepErrors: true, keepIsValid: true });
|
||||
form.setError(REMOTE_ERROR_FORM_FIELD, { type: 'remote', message: message });
|
||||
|
||||
// Throw error to prevent dialog from closing
|
||||
throw error;
|
||||
}
|
||||
|
||||
showAlertDialog({
|
||||
@@ -176,14 +168,7 @@ export const AddLocationDialog = ({ path, ...dialogProps }: Props) => {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<ErrorMessage
|
||||
name={REMOTE_ERROR_FORM_FIELD}
|
||||
render={({ message }) => (
|
||||
<span className="mt-5 inline-block w-full whitespace-pre-wrap text-center text-sm font-semibold text-red-500">
|
||||
{message}
|
||||
</span>
|
||||
)}
|
||||
/>
|
||||
<ErrorMessage name={REMOTE_ERROR_FORM_FIELD} variant="large" className="mt-3" />
|
||||
</Dialog>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -1,70 +1,549 @@
|
||||
// import { PlusSquare } from '@phosphor-icons/react';
|
||||
import clsx from 'clsx';
|
||||
import { ControllerRenderProps, FieldPath } from 'react-hook-form';
|
||||
import { useLibraryQuery } from '@sd/client';
|
||||
import { Button, Card } from '@sd/ui';
|
||||
import { CaretRight, Info, Plus, Trash, X } from 'phosphor-react';
|
||||
import { ComponentProps, createRef, forwardRef, useEffect, useId, useState } from 'react';
|
||||
import { createPortal } from 'react-dom';
|
||||
import { Controller, ControllerRenderProps, FormProvider } from 'react-hook-form';
|
||||
import {
|
||||
RuleKind,
|
||||
UnionToTuple,
|
||||
extractInfoRSPCError,
|
||||
isKeyOf,
|
||||
useLibraryMutation,
|
||||
useLibraryQuery
|
||||
} from '@sd/client';
|
||||
import { Button, Card, Divider, Input, Switch, Tabs, Tooltip, inputSizes } from '@sd/ui';
|
||||
import { ErrorMessage, Form, Input as FormInput, useZodForm, z } from '@sd/ui/src/forms';
|
||||
import { InfoPill } from '~/app/$libraryId/Explorer/Inspector';
|
||||
import { showAlertDialog } from '~/components/AlertDialog';
|
||||
import { useOperatingSystem } from '~/hooks/useOperatingSystem';
|
||||
import { usePlatform } from '~/util/Platform';
|
||||
import { openDirectoryPickerDialog } from './AddLocationDialog';
|
||||
|
||||
interface FormFields {
|
||||
indexerRulesIds: number[];
|
||||
// NOTE: This should be updated whenever RuleKind is changed
|
||||
const ruleKinds: UnionToTuple<RuleKind> = [
|
||||
'AcceptFilesByGlob',
|
||||
'RejectFilesByGlob',
|
||||
'AcceptIfChildrenDirectoriesArePresent',
|
||||
'RejectIfChildrenDirectoriesArePresent'
|
||||
];
|
||||
|
||||
interface RulesInputProps {
|
||||
form: string;
|
||||
onChange: ComponentProps<'input'>['onChange'];
|
||||
className: string;
|
||||
onInvalid: ComponentProps<'input'>['onInvalid'];
|
||||
}
|
||||
|
||||
type FieldType = ControllerRenderProps<
|
||||
FormFields,
|
||||
Exclude<FieldPath<FormFields>, `indexerRulesIds.${number}`>
|
||||
const RuleTabsInput = {
|
||||
Name: forwardRef<HTMLInputElement, RulesInputProps>((props, ref) => {
|
||||
const os = useOperatingSystem(true);
|
||||
return (
|
||||
<Input
|
||||
ref={ref}
|
||||
size="md"
|
||||
// TODO: The check here shouldn't be for which os the UI is running, but for which os the node is running
|
||||
pattern={os === 'windows' ? '[^<>:"/\\|?*\u0000-\u0031]*' : '[^/\0]+'}
|
||||
placeholder="File/Directory name"
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}),
|
||||
Extension: forwardRef<HTMLInputElement, RulesInputProps>((props, ref) => (
|
||||
<Input
|
||||
ref={ref}
|
||||
size="md"
|
||||
pattern="^\.[^\.\s]+$"
|
||||
aria-label="Add a file extension to the current rule"
|
||||
placeholder="File extension (e.g., .mp4, .jpg, .txt)"
|
||||
{...props}
|
||||
/>
|
||||
)),
|
||||
Path: forwardRef<HTMLInputElement, RulesInputProps>(({ className, ...props }, ref) => {
|
||||
const os = useOperatingSystem(true);
|
||||
const platform = usePlatform();
|
||||
const isWeb = platform.platform === 'web';
|
||||
return (
|
||||
<Input
|
||||
ref={ref}
|
||||
size="md"
|
||||
pattern={
|
||||
isWeb
|
||||
? // Non web plataforms use the native file picker, so there is no need to validate
|
||||
''
|
||||
: // TODO: The check here shouldn't be for which os the UI is running, but for which os the node is running
|
||||
os === 'windows'
|
||||
? '[^<>:"/|?*\u0000-\u0031]*'
|
||||
: '[^\0]+'
|
||||
}
|
||||
readOnly={!isWeb}
|
||||
className={clsx(className, isWeb || 'cursor-pointer')}
|
||||
placeholder={
|
||||
'Path (e.g., ' +
|
||||
// TODO: The check here shouldn't be for which os the UI is running, but for which os the node is running
|
||||
(os === 'windows'
|
||||
? 'C:\\Users\\john\\Downloads'
|
||||
: os === 'macOS'
|
||||
? '/Users/clara/Pictures'
|
||||
: '/home/emily/Documents') +
|
||||
')'
|
||||
}
|
||||
onClick={(e) => {
|
||||
openDirectoryPickerDialog(platform)
|
||||
.then((path) => {
|
||||
if (path) (e.target as HTMLInputElement).value = path;
|
||||
})
|
||||
.catch((error) =>
|
||||
showAlertDialog({
|
||||
title: 'Error',
|
||||
value: String(error)
|
||||
})
|
||||
);
|
||||
}}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}),
|
||||
Advanced: forwardRef<HTMLInputElement, RulesInputProps>((props, ref) => {
|
||||
const os = useOperatingSystem(true);
|
||||
return (
|
||||
<Input
|
||||
ref={ref}
|
||||
size="md"
|
||||
pattern={
|
||||
// TODO: The check here shouldn't be for which os the UI is running, but for which os the node is running
|
||||
os === 'windows' ? '[^<>:"\u0000-\u0031]*' : '[^\0]+'
|
||||
}
|
||||
placeholder="Glob (e.g., **/.git)"
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
})
|
||||
};
|
||||
|
||||
type RuleType = keyof typeof RuleTabsInput;
|
||||
|
||||
type ParametersFieldType = ControllerRenderProps<
|
||||
{ parameters: [RuleType, string][] },
|
||||
'parameters'
|
||||
>;
|
||||
|
||||
export interface IndexerRuleEditorProps<T extends FieldType> {
|
||||
interface RuleTabsContentProps<T extends ParametersFieldType> {
|
||||
form: string;
|
||||
field: T;
|
||||
value: RuleType;
|
||||
}
|
||||
|
||||
function RuleTabsContent<T extends ParametersFieldType>({
|
||||
form,
|
||||
value,
|
||||
field,
|
||||
...props
|
||||
}: RuleTabsContentProps<T>) {
|
||||
const [invalid, setInvalid] = useState(false);
|
||||
const inputRef = createRef<HTMLInputElement>();
|
||||
const RuleInput = RuleTabsInput[value];
|
||||
|
||||
return (
|
||||
<Tabs.Content asChild value={value} {...props}>
|
||||
<div className="flex flex-row justify-between pt-4">
|
||||
<RuleInput
|
||||
ref={inputRef}
|
||||
form={form}
|
||||
onChange={(e) => {
|
||||
const input = e.target;
|
||||
setInvalid(false);
|
||||
|
||||
// Even if the input value is valid, without clearing the custom validity, the invalid state will remain
|
||||
input.setCustomValidity('');
|
||||
|
||||
input.reportValidity();
|
||||
}}
|
||||
onInvalid={(e) => {
|
||||
// Required to prevent the browser from showing the default error message
|
||||
(e.target as HTMLInputElement).setCustomValidity(' ');
|
||||
setInvalid(true);
|
||||
}}
|
||||
className={clsx('mr-2 flex-1', invalid && '!ring-2 !ring-red-500')}
|
||||
/>
|
||||
<Button
|
||||
onClick={() => {
|
||||
const { current: input } = inputRef;
|
||||
if (!(input && input.checkValidity()) || input.value.trim() === '') return;
|
||||
field.onChange([...field.value, [value, input.value]]);
|
||||
input.value = '';
|
||||
}}
|
||||
variant="accent"
|
||||
>
|
||||
<Plus />
|
||||
</Button>
|
||||
</div>
|
||||
</Tabs.Content>
|
||||
);
|
||||
}
|
||||
|
||||
type IndexerRuleIdFieldType = ControllerRenderProps<
|
||||
{ indexerRulesIds: number[] },
|
||||
'indexerRulesIds'
|
||||
>;
|
||||
|
||||
export interface IndexerRuleEditorProps<T extends IndexerRuleIdFieldType> {
|
||||
field?: T;
|
||||
editable?: boolean;
|
||||
}
|
||||
|
||||
export function IndexerRuleEditor<T extends FieldType>({
|
||||
const ruleKindEnum = z.enum(ruleKinds);
|
||||
|
||||
const newRuleSchema = z.object({
|
||||
kind: ruleKindEnum,
|
||||
name: z.string().min(3),
|
||||
parameters: z
|
||||
.array(z.tuple([z.enum(Object.keys(RuleTabsInput) as UnionToTuple<RuleType>), z.string()]))
|
||||
.nonempty()
|
||||
});
|
||||
|
||||
const REMOTE_ERROR_FORM_FIELD = 'root.serverError';
|
||||
|
||||
const removeParameter = <T extends ParametersFieldType>(field: T, index: number) =>
|
||||
field.onChange(field.value.slice(0, index).concat(field.value.slice(index + 1)));
|
||||
|
||||
export function IndexerRuleEditor<T extends IndexerRuleIdFieldType>({
|
||||
field,
|
||||
editable
|
||||
}: IndexerRuleEditorProps<T>) {
|
||||
const form = useZodForm({
|
||||
schema: newRuleSchema,
|
||||
defaultValues: { name: '', kind: 'RejectFilesByGlob', parameters: [] }
|
||||
});
|
||||
const formId = useId();
|
||||
const listIndexerRules = useLibraryQuery(['locations.indexer_rules.list']);
|
||||
const indexRules = listIndexerRules.data;
|
||||
return (
|
||||
<Card className="mb-2 flex flex-wrap justify-evenly">
|
||||
{indexRules ? (
|
||||
indexRules.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)
|
||||
: Array.from(new Set([...field.value, rule.id]))
|
||||
)
|
||||
}
|
||||
variant={enabled ? 'colored' : 'outline'}
|
||||
className={clsx('m-1 flex-auto', enabled && 'border-accent bg-accent')}
|
||||
>
|
||||
{name}
|
||||
</Button>
|
||||
);
|
||||
const deleteIndexerRule = useLibraryMutation(['locations.indexer_rules.delete']);
|
||||
const createIndexerRules = useLibraryMutation(['locations.indexer_rules.create']);
|
||||
const [currentTab, setCurrentTab] = useState<RuleType>('Name');
|
||||
const [isDeleting, setIsDeleting] = useState<boolean>(false);
|
||||
const [showCreateNewRule, setShowCreateNewRule] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
// TODO: Instead of clearing the error on every change, the backend should suport a way to validate without committing
|
||||
const subscription = form.watch(() => {
|
||||
form.clearErrors(REMOTE_ERROR_FORM_FIELD);
|
||||
});
|
||||
return () => subscription.unsubscribe();
|
||||
}, [form]);
|
||||
|
||||
const onSubmit = form.handleSubmit(({ kind, name, parameters }) =>
|
||||
createIndexerRules
|
||||
.mutateAsync({
|
||||
kind,
|
||||
name,
|
||||
parameters: parameters.flatMap(([kind, rule]) => {
|
||||
switch (kind) {
|
||||
case 'Name':
|
||||
return `**/${rule}`;
|
||||
case 'Extension':
|
||||
// .tar should work for .tar.gz, .tar.bz2, etc.
|
||||
return [`**/*${rule}`, `**/*${rule}.*`];
|
||||
default:
|
||||
return rule;
|
||||
}
|
||||
})
|
||||
) : (
|
||||
<p className={clsx(listIndexerRules.isError && 'text-red-500')}>
|
||||
{listIndexerRules.isError
|
||||
? 'Error while retriving indexer rules'
|
||||
: 'No indexer rules available'}
|
||||
</p>
|
||||
})
|
||||
.then(async () => {
|
||||
await listIndexerRules.refetch();
|
||||
form.reset();
|
||||
}, onSubmitError)
|
||||
);
|
||||
|
||||
const onSubmitError = (error: Error) => {
|
||||
const rspcErrorInfo = extractInfoRSPCError(error);
|
||||
if (rspcErrorInfo && rspcErrorInfo.code !== 500) {
|
||||
form.reset({}, { keepValues: true, keepErrors: true, keepIsValid: true });
|
||||
form.setError(REMOTE_ERROR_FORM_FIELD, {
|
||||
type: 'remote',
|
||||
message: rspcErrorInfo.message
|
||||
});
|
||||
} else {
|
||||
showAlertDialog({
|
||||
title: 'Error',
|
||||
value: String(error) || 'Failed to add location'
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const indexRules = listIndexerRules.data;
|
||||
const {
|
||||
formState: { isSubmitting: isFormSubmitting, errors: formErrors }
|
||||
} = form;
|
||||
return (
|
||||
<>
|
||||
<Card className="mb-2 flex flex-wrap justify-evenly">
|
||||
{indexRules ? (
|
||||
indexRules.map((rule) => {
|
||||
const value = field?.value ?? [];
|
||||
const enabled = value.includes(rule.id);
|
||||
return (
|
||||
<Button
|
||||
key={rule.id}
|
||||
size="sm"
|
||||
onClick={
|
||||
field &&
|
||||
(() =>
|
||||
field.onChange(
|
||||
enabled
|
||||
? value.filter((v) => v !== rule.id)
|
||||
: Array.from(new Set([...value, rule.id]))
|
||||
))
|
||||
}
|
||||
variant={enabled ? 'colored' : 'outline'}
|
||||
disabled={isFormSubmitting || isDeleting || !field}
|
||||
className={clsx(
|
||||
'relative m-1 flex-auto overflow-hidden',
|
||||
enabled && 'border-accent bg-accent'
|
||||
)}
|
||||
>
|
||||
{rule.name}
|
||||
{editable && !rule.default && (
|
||||
<X
|
||||
size={12}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
const elem = e.target as SVGElement;
|
||||
if (elem.classList.contains('w-full')) {
|
||||
deleteIndexerRule
|
||||
.mutateAsync(rule.id)
|
||||
.then(
|
||||
() => listIndexerRules.refetch(),
|
||||
(error) =>
|
||||
showAlertDialog({
|
||||
title: 'Error',
|
||||
value:
|
||||
String(error) ||
|
||||
'Failed to add location'
|
||||
})
|
||||
)
|
||||
.finally(() => setIsDeleting(false));
|
||||
setIsDeleting(true);
|
||||
} else {
|
||||
elem.classList.add('w-full');
|
||||
}
|
||||
}}
|
||||
onMouseLeave={(e) => {
|
||||
const elem = e.target as SVGElement;
|
||||
elem.classList.remove('w-full');
|
||||
}}
|
||||
className="absolute right-0 top-0 h-full cursor-pointer bg-red-500 transition-all"
|
||||
/>
|
||||
)}
|
||||
</Button>
|
||||
);
|
||||
})
|
||||
) : (
|
||||
<p className={clsx(listIndexerRules.isError && 'text-red-500')}>
|
||||
{listIndexerRules.isError
|
||||
? 'Error while retriving indexer rules'
|
||||
: 'No indexer rules available'}
|
||||
</p>
|
||||
)}
|
||||
</Card>
|
||||
|
||||
{
|
||||
// Portal is required for Form because this component can be inside another form element
|
||||
createPortal(
|
||||
<Form
|
||||
id={formId}
|
||||
form={form}
|
||||
disabled={isFormSubmitting}
|
||||
onSubmit={onSubmit}
|
||||
className="hidden h-0 w-0"
|
||||
/>,
|
||||
document.body
|
||||
)
|
||||
}
|
||||
|
||||
{editable && (
|
||||
<FormProvider {...form}>
|
||||
<div className="rounded-md border border-app-line bg-app-overlay">
|
||||
<Button
|
||||
variant="bare"
|
||||
className={clsx(
|
||||
'flex w-full border-none !p-3',
|
||||
showCreateNewRule && 'rounded-b-none'
|
||||
)}
|
||||
onClick={() => setShowCreateNewRule(!showCreateNewRule)}
|
||||
>
|
||||
Create new indexer rule
|
||||
<CaretRight
|
||||
weight="bold"
|
||||
className={clsx(
|
||||
'ml-1 transition',
|
||||
showCreateNewRule && 'rotate-90'
|
||||
)}
|
||||
/>
|
||||
</Button>
|
||||
|
||||
{showCreateNewRule && (
|
||||
<div className="px-4 pb-4 pt-2">
|
||||
<h3 className="w-full text-center text-sm font-semibold">Rules</h3>
|
||||
|
||||
<Divider className="mb-2" />
|
||||
|
||||
<Controller
|
||||
name="parameters"
|
||||
render={({ field }) => (
|
||||
<>
|
||||
<div
|
||||
className={clsx(
|
||||
formErrors.parameters &&
|
||||
'!ring-1 !ring-red-500',
|
||||
'grid space-y-1 rounded-md border border-app-line/60 bg-app-overlay p-2'
|
||||
)}
|
||||
>
|
||||
{((rules) =>
|
||||
rules.length === 0 ? (
|
||||
<p className="w-full p-2 text-center text-sm text-ink-dull">
|
||||
No rules yet
|
||||
</p>
|
||||
) : (
|
||||
rules.map(([kind, rule], index) => (
|
||||
<Card
|
||||
key={index}
|
||||
className="border-app-line/30 hover:bg-app-box/70"
|
||||
>
|
||||
<InfoPill className="mr-2 p-0.5">
|
||||
{kind}
|
||||
</InfoPill>
|
||||
|
||||
<p className="p-0.5 text-sm font-semibold text-ink-dull">
|
||||
{rule}
|
||||
</p>
|
||||
|
||||
<div className="grow" />
|
||||
|
||||
{/* <p className="mx-2 rounded-md border border-app-line/30 bg-app-overlay/80 py-1 px-2 text-center text-sm text-ink-dull">
|
||||
{kind}
|
||||
</p> */}
|
||||
|
||||
<Button
|
||||
variant="gray"
|
||||
onClick={() =>
|
||||
removeParameter(
|
||||
field,
|
||||
index
|
||||
)
|
||||
}
|
||||
>
|
||||
<Tooltip label="Delete rule">
|
||||
<Trash size={14} />
|
||||
</Tooltip>
|
||||
</Button>
|
||||
</Card>
|
||||
))
|
||||
))(form.getValues().parameters)}
|
||||
</div>
|
||||
|
||||
<ErrorMessage name="parameters" className="mt-1" />
|
||||
|
||||
<Tabs.Root
|
||||
value={currentTab}
|
||||
onValueChange={(tab) =>
|
||||
isKeyOf(tab, RuleTabsInput) &&
|
||||
setCurrentTab(tab)
|
||||
}
|
||||
>
|
||||
<Tabs.List className="flex flex-row">
|
||||
{Object.keys(RuleTabsInput).map((name) => (
|
||||
<Tabs.Trigger
|
||||
className="flex-auto !rounded-md py-2 text-sm font-medium"
|
||||
key={name}
|
||||
value={name}
|
||||
>
|
||||
{name}
|
||||
</Tabs.Trigger>
|
||||
))}
|
||||
</Tabs.List>
|
||||
|
||||
{...(Object.keys(RuleTabsInput) as RuleType[]).map(
|
||||
(name) => (
|
||||
<RuleTabsContent
|
||||
key={name}
|
||||
form={formId}
|
||||
value={name}
|
||||
field={field}
|
||||
/>
|
||||
)
|
||||
)}
|
||||
</Tabs.Root>
|
||||
</>
|
||||
)}
|
||||
control={form.control}
|
||||
/>
|
||||
|
||||
<h3 className="mt-4 w-full text-center text-sm font-semibold">
|
||||
Settings
|
||||
</h3>
|
||||
|
||||
<Divider className="mb-2" />
|
||||
|
||||
<div className="mb-2 flex flex-row justify-between">
|
||||
<div className="mr-2 grow">
|
||||
<FormInput
|
||||
size="md"
|
||||
form={formId}
|
||||
placeholder="Name"
|
||||
{...form.register('name')}
|
||||
/>
|
||||
|
||||
<div className="mt-2 flex w-full flex-row">
|
||||
<label className="grow text-sm font-medium">
|
||||
Indexer rule is an allow list{' '}
|
||||
<Tooltip label="By default, an indexer rule acts as a deny list, causing a location to ignore any file that match its rules. Enabling this will make it act as an allow list, and the location will only display files that match its rules.">
|
||||
<Info className="inline" />
|
||||
</Tooltip>
|
||||
</label>
|
||||
|
||||
<Controller
|
||||
name="kind"
|
||||
render={({ field }) => (
|
||||
<Switch
|
||||
onCheckedChange={(checked) => {
|
||||
// TODO: This rule kinds are broken right now in the backend and this UI doesn't make much sense for it
|
||||
// kind.AcceptIfChildrenDirectoriesArePresent
|
||||
// kind.RejectIfChildrenDirectoriesArePresent
|
||||
const kind = ruleKindEnum.enum;
|
||||
field.onChange(
|
||||
checked
|
||||
? kind.AcceptFilesByGlob
|
||||
: kind.RejectFilesByGlob
|
||||
);
|
||||
}}
|
||||
size="sm"
|
||||
/>
|
||||
)}
|
||||
control={form.control}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Button
|
||||
size="sm"
|
||||
type="submit"
|
||||
form={formId}
|
||||
variant={isFormSubmitting ? 'outline' : 'accent'}
|
||||
className={inputSizes.md}
|
||||
>
|
||||
<Plus />
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<ErrorMessage
|
||||
name={REMOTE_ERROR_FORM_FIELD}
|
||||
variant="large"
|
||||
className="mt-2"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</FormProvider>
|
||||
)}
|
||||
{/* {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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -18,8 +18,6 @@
|
||||
"dependencies": {
|
||||
"@fontsource/inter": "^4.5.13",
|
||||
"@headlessui/react": "^1.7.3",
|
||||
"@hookform/error-message": "^2.0.1",
|
||||
"@hookform/resolvers": "^2.9.10",
|
||||
"@radix-ui/react-progress": "^1.0.1",
|
||||
"@radix-ui/react-slider": "^1.1.0",
|
||||
"@radix-ui/react-toast": "^1.1.2",
|
||||
@@ -49,7 +47,7 @@
|
||||
"react-colorful": "^5.6.1",
|
||||
"react-dom": "^18.2.0",
|
||||
"react-error-boundary": "^3.1.4",
|
||||
"react-hook-form": "^7.43.5",
|
||||
"react-hook-form": "^7.43.9",
|
||||
"react-json-view": "^1.21.3",
|
||||
"react-loading-skeleton": "^3.1.0",
|
||||
"react-qr-code": "^2.0.11",
|
||||
@@ -60,8 +58,7 @@
|
||||
"tailwindcss": "^3.1.8",
|
||||
"use-count-up": "^3.0.1",
|
||||
"use-debounce": "^8.0.4",
|
||||
"valtio": "^1.7.4",
|
||||
"zod": "^3.20.2"
|
||||
"valtio": "^1.7.4"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@sd/config": "workspace:*",
|
||||
|
||||
@@ -22,7 +22,8 @@
|
||||
"@zxcvbn-ts/language-common": "^2.0.1",
|
||||
"@zxcvbn-ts/language-en": "^2.1.0",
|
||||
"plausible-tracker": "^0.3.8",
|
||||
"valtio": "^1.7.4"
|
||||
"valtio": "^1.7.4",
|
||||
"zod": "^3.21.4"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/react": "^18.0.21",
|
||||
|
||||
@@ -155,7 +155,7 @@ export type IndexerRule = { id: number, kind: number, name: string, default: boo
|
||||
* In case of `RuleKind::AcceptIfChildrenDirectoriesArePresent` or `RuleKind::RejectIfChildrenDirectoriesArePresent` the
|
||||
* `parameters` field must be a vector of strings containing the names of the directories.
|
||||
*/
|
||||
export type IndexerRuleCreateArgs = { kind: RuleKind, name: string, parameters: number[] }
|
||||
export type IndexerRuleCreateArgs = { kind: RuleKind, name: string, parameters: string[] }
|
||||
|
||||
export type InvalidateOperationEvent = { key: string, arg: any, result: any | null }
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { ProcedureDef } from '@rspc/client';
|
||||
import { ProcedureDef, RSPCError } from '@rspc/client';
|
||||
import { internal_createReactHooksFactory } from '@rspc/react';
|
||||
import { LibraryArgs, Procedures } from './core';
|
||||
import { currentLibraryCache } from './hooks';
|
||||
@@ -99,3 +99,22 @@ export function useInvalidateQuery() {
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
export function extractInfoRSPCError(error: unknown) {
|
||||
if (
|
||||
error == null ||
|
||||
typeof error !== 'object' ||
|
||||
!('cause' in error && error.cause instanceof RSPCError)
|
||||
)
|
||||
return null;
|
||||
|
||||
// TODO: error.code property is not yet implemented in RSPCError
|
||||
// https://github.com/oscartbeaumont/rspc/blob/60a4fa93187c20bc5cb565cc6ee30b2f0903840e/packages/client/src/interop/error.ts#L59
|
||||
// So we grab it from the shape for now
|
||||
const { code } = error.cause.shape;
|
||||
|
||||
return {
|
||||
code: Number.isInteger(code) ? code : 500,
|
||||
message: 'message' in error ? String(error.message) : ''
|
||||
};
|
||||
}
|
||||
|
||||
@@ -19,3 +19,21 @@ export function arraysEqual<T>(a: T[], b: T[]) {
|
||||
|
||||
return a.every((n, i) => b[i] === n);
|
||||
}
|
||||
|
||||
export function isKeyOf<T extends object>(key: PropertyKey, obj: T): key is keyof T {
|
||||
return key in obj;
|
||||
}
|
||||
|
||||
// From: https://github.com/microsoft/TypeScript/issues/13298#issuecomment-885980381
|
||||
// Warning: Avoid using the types bellow as a generic parameter, as it tanks the typechecker performance
|
||||
export type UnionToIntersection<U> = (U extends never ? never : (arg: U) => never) extends (
|
||||
arg: infer I
|
||||
) => void
|
||||
? I
|
||||
: never;
|
||||
|
||||
export type UnionToTuple<T> = UnionToIntersection<T extends never ? never : (t: T) => T> extends (
|
||||
_: never
|
||||
) => infer W
|
||||
? [...UnionToTuple<Exclude<T, W>>, W]
|
||||
: [];
|
||||
|
||||
@@ -21,6 +21,8 @@
|
||||
"dependencies": {
|
||||
"@headlessui/react": "^1.7.3",
|
||||
"@headlessui/tailwindcss": "^0.1.1",
|
||||
"@hookform/error-message": "^2.0.1",
|
||||
"@hookform/resolvers": "^3.1.0",
|
||||
"@radix-ui/react-checkbox": "^1.0.3",
|
||||
"@radix-ui/react-context-menu": "^1.0.0",
|
||||
"@radix-ui/react-dialog": "^1.0.0",
|
||||
@@ -42,7 +44,8 @@
|
||||
"react-router-dom": "6.9.0",
|
||||
"react-spring": "^9.5.5",
|
||||
"tailwindcss-radix": "^2.6.0",
|
||||
"use-debounce": "^9.0.4"
|
||||
"use-debounce": "^9.0.4",
|
||||
"zod": "^3.21.4"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@babel/core": "^7.19.3",
|
||||
|
||||
@@ -14,6 +14,12 @@ export type InputProps = InputBaseProps & Omit<React.ComponentProps<'input'>, 's
|
||||
|
||||
export type TextareaProps = InputBaseProps & React.ComponentProps<'textarea'>;
|
||||
|
||||
export const inputSizes = {
|
||||
sm: 'h-[30px]',
|
||||
md: 'h-[34px]',
|
||||
lg: 'h-[38px]'
|
||||
};
|
||||
|
||||
export const inputStyles = cva(
|
||||
[
|
||||
'rounded-md border text-sm leading-7',
|
||||
@@ -30,11 +36,7 @@ export const inputStyles = cva(
|
||||
error: {
|
||||
true: 'border-red-500 focus-within:border-red-500 focus-within:ring-red-400/30'
|
||||
},
|
||||
size: {
|
||||
sm: 'h-[30px]',
|
||||
md: 'h-[34px]',
|
||||
lg: 'h-[38px]'
|
||||
}
|
||||
size: inputSizes
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: 'default',
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
import { ErrorMessage as ErrorMessagePrimitive } from '@hookform/error-message';
|
||||
import { zodResolver } from '@hookform/resolvers/zod';
|
||||
import { VariantProps, cva } from 'class-variance-authority';
|
||||
import { ComponentProps } from 'react';
|
||||
import {
|
||||
FieldValues,
|
||||
@@ -50,10 +52,32 @@ export const useZodForm = <S extends z.ZodSchema = z.ZodObject<Record<string, ne
|
||||
) => {
|
||||
const { schema, ...formProps } = props ?? {};
|
||||
|
||||
return useForm({
|
||||
return useForm<z.infer<S>>({
|
||||
...formProps,
|
||||
resolver: zodResolver(schema || z.object({}))
|
||||
});
|
||||
};
|
||||
|
||||
export const errorStyles = cva('inline-block whitespace-pre-wrap text-red-500', {
|
||||
variants: {
|
||||
variant: {
|
||||
none: '',
|
||||
default: 'text-xs',
|
||||
large: 'w-full text-center text-sm font-semibold'
|
||||
}
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: 'default'
|
||||
}
|
||||
});
|
||||
|
||||
export interface ErrorMessageProps extends VariantProps<typeof errorStyles> {
|
||||
name: string;
|
||||
className: string;
|
||||
}
|
||||
|
||||
export const ErrorMessage = ({ name, variant, className }: ErrorMessageProps) => (
|
||||
<ErrorMessagePrimitive as="span" name={name} className={errorStyles({ variant, className })} />
|
||||
);
|
||||
|
||||
export { z } from 'zod';
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { PropsWithChildren, ReactNode, useId } from 'react';
|
||||
import { useFormContext } from 'react-hook-form';
|
||||
import { ErrorMessage } from './Form';
|
||||
|
||||
export interface UseFormFieldProps extends PropsWithChildren {
|
||||
name: string;
|
||||
@@ -21,7 +22,7 @@ export const useFormField = <P extends UseFormFieldProps>(props: P) => {
|
||||
|
||||
interface FormFieldProps extends Omit<UseFormFieldProps, 'label'> {
|
||||
id: string;
|
||||
error?: string;
|
||||
name: string;
|
||||
label?: string | ReactNode;
|
||||
}
|
||||
|
||||
@@ -34,7 +35,7 @@ export const FormField = (props: FormFieldProps) => {
|
||||
</label>
|
||||
)}
|
||||
{props.children}
|
||||
{props.error && <span className="mt-1 w-full text-xs text-red-500">{props.error}</span>}
|
||||
<ErrorMessage name={props.name} className="mt-1 w-full text-xs" />
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
BIN
pnpm-lock.yaml
generated
BIN
pnpm-lock.yaml
generated
Binary file not shown.
Reference in New Issue
Block a user