[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:
Vítor Vasconcellos
2023-04-22 04:30:11 +00:00
committed by GitHub
parent f2db45fec6
commit 31a87f6794
17 changed files with 670 additions and 119 deletions

View File

@@ -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",

View File

@@ -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?;

View File

@@ -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

View File

@@ -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*",

View File

@@ -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>

View File

@@ -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>
);
};

View File

@@ -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>
</>
);
}

View File

@@ -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:*",

View File

@@ -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",

View File

@@ -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 }

View File

@@ -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) : ''
};
}

View File

@@ -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]
: [];

View File

@@ -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",

View File

@@ -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',

View File

@@ -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';

View File

@@ -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
View File

Binary file not shown.