Skip to content

Commit

Permalink
feat(uniform): add initial package
Browse files Browse the repository at this point in the history
  • Loading branch information
toxsick committed Apr 15, 2024
1 parent cb00e92 commit c189b28
Show file tree
Hide file tree
Showing 40 changed files with 1,982 additions and 65 deletions.
3 changes: 2 additions & 1 deletion .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -26,9 +26,10 @@ pids
*.seed

# Folders
.turbo-cache
build
dist
coverage
dist
node_modules

# Extentions
Expand Down
6 changes: 3 additions & 3 deletions .npmrc
Original file line number Diff line number Diff line change
@@ -1,17 +1,17 @@
# see: https://pnpm.io/next/npmrc#auto-install-peers
auto-install-peers=true

# storybook-config
# @repo/storybook-config
public-hoist-pattern[]=@testing-library/*
public-hoist-pattern[]=*storybook*
public-hoist-pattern[]=jsdom
public-hoist-pattern[]=nyc
public-hoist-pattern[]=playwright

# tailwind-config
# @repo/tailwind-config
public-hoist-pattern[]=tailwindcss

# vite-config
# @repo/vite-config
public-hoist-pattern[]=@vitejs/plugin-react
public-hoist-pattern[]=vite
public-hoist-pattern[]=vite-tsconfig-paths
Expand Down
6 changes: 5 additions & 1 deletion Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -13,8 +13,12 @@ install:
pnpm env use --global `cat .nvmrc`;
pnpm install --ignore-scripts;
pnpm husky install;
pnpm build;

storybook:
@$(MAKE) install;
pnpm --filter veto build;
pnpm --filter storybook-config storybook;

test:
@$(MAKE) install;
pnpm test;
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@
},
"devDependencies": {
"@types/node": "20.12.5",
"@fuf-stack/eslint-config-fuf": "0.8.3",
"@fuf-stack/eslint-config-fuf": "0.9.0",
"@fuf-stack/project-cli-tools": "0.4.1",
"@fuf-stack/vitest-config": "0.1.1",
"ts-node": "10.9.2",
Expand Down
3 changes: 1 addition & 2 deletions packages/pixels/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -35,8 +35,7 @@
"url": "https://github.com/fuf-stack/uniforms/issues"
},
"scripts": {
"build": "rm -rf ./dist && tsup",
"prepack": "pnpm build",
"prepack": "rm -rf ./dist && tsup",
"test": "vitest ./src"
},
"dependencies": {
Expand Down
8 changes: 8 additions & 0 deletions packages/uniform/.storybook/main.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
import sharedConfig, { StorybookConfig } from '@repo/storybook-config/main';

const config: StorybookConfig = {
...sharedConfig,
stories: ['../src/**/*.stories.@(js|jsx|ts|tsx)'],
};

export default config;
3 changes: 3 additions & 0 deletions packages/uniform/.storybook/preview-head.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
<script>
window.global = window;
</script>
7 changes: 7 additions & 0 deletions packages/uniform/.storybook/preview.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
import sharedPreview, { Preview } from 'storybook-config/preview';

const preview: Preview = {
...sharedPreview,
};

export default preview;
3 changes: 3 additions & 0 deletions packages/uniform/Globals.d.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
declare module '*.module.css';
declare module '*.png';
declare module '*.svg';
71 changes: 71 additions & 0 deletions packages/uniform/package.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,71 @@
{
"name": "@fuf-stack/uniform",
"version": "0.0.1",
"description": "fuf react form library",
"author": "Hannes Tiede",
"homepage": "https://github.com/fuf-stack/uniform#readme",
"license": "MIT",
"type": "module",
"exports": {
"./": "./dist/"
},
"typesVersions": {
"*": {
"*": [
"./dist/*"
]
}
},
"files": [
"dist"
],
"publishConfig": {
"access": "public"
},
"repository": {
"type": "git",
"url": "git+https://github.com/fuf-stack/uniform.git",
"directory": "packages/uniform"
},
"bugs": {
"url": "https://github.com/fuf-stack/uniform/issues"
},
"scripts": {
"prepack": "rm -rf ./dist && tsup",
"test": "vitest ./src"
},
"dependencies": {
"@dnd-kit/core": "6.1.0",
"@dnd-kit/modifiers": "7.0.0",
"@dnd-kit/sortable": "8.0.0",
"@dnd-kit/utilities": "3.2.2",
"@fuf-stack/pixels": "workspace:*",
"@fuf-stack/veto": "workspace:*",
"@nextui-org/button": "2.0.27",
"@nextui-org/checkbox": "2.0.25",
"@nextui-org/input": "2.1.17",
"@nextui-org/radio": "2.0.25",
"@nextui-org/select": "2.1.21",
"@nextui-org/switch": "2.0.25",
"@nextui-org/system": "2.0.15",
"@react-aria/visually-hidden": "3.8.10",
"react-icons": "5.0.1",
"classnames": "2.5.1",
"debug": "4.3.4",
"react": "18.2.0",
"react-dom": "18.2.0",
"react-hook-form": "7.51.0",
"react-select": "5.8.0",
"slug": "9.0.0",
"tailwind-variants": "0.1.20"
},
"devDependencies": {
"@repo/storybook-config": "workspace:*",
"@repo/tailwind-config": "workspace:*",
"@repo/vite-config": "workspace:*",
"@types/debug": "4.1.12",
"@types/react": "18.2.69",
"@types/react-dom": "18.2.22",
"@types/slug": "5.0.8"
}
}
109 changes: 109 additions & 0 deletions packages/uniform/src/Form/Form.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,109 @@
import type { VetoInstance } from '@fuf-stack/veto';
import type { ReactNode } from 'react';
import type { FieldValues, SubmitHandler } from 'react-hook-form';

import { useEffect } from 'react';
import { useForm } from 'react-hook-form';

import cn from 'classnames';

import slugify from '../helpers/slugify';
import FormProvider from './subcomponents/FormContext';
import FormDebugViewer from './subcomponents/FormDebugViewer';

/**
* recursively removes all fields that are null or undefined before
* the form data is passed to the veto validation function
*/
export const removeNullishFields = (obj: Record<string, unknown>) => {
return JSON.parse(
JSON.stringify(obj, (_key, value) => {
return value === null ? undefined : value;
}),
);
};

interface FormProps {
/** form children */
children: ReactNode | ReactNode[];
/** CSS class name */
className?: string | string[];
/** initial form values */
initialValues?: FieldValues;
/** name of the form */
name?: string;
/** form submit handler */
onSubmit: SubmitHandler<FieldValues>;
/** HTML data-testid attribute used in e2e tests */
testId?: string;
/** veto validation schema */
validation?: VetoInstance;
/** when the validation should be triggered */
validationTrigger?:
| 'onChange'
| 'onBlur'
| 'onSubmit'
| 'onTouched'
| 'all'
| 'all-instant';
}

/**
* Form component that has to wrap every uniform
*/
const Form = ({
children,
className = undefined,
initialValues = undefined,
name = undefined,
onSubmit,
testId = undefined,
validation = undefined,
validationTrigger = 'all',
}: FormProps) => {
const trigger =
validationTrigger === 'all-instant' ? 'all' : validationTrigger;

const methods = useForm(
validation
? {
defaultValues: initialValues,
resolver: async (values, ...args) => {
const { data, errors, ...rest } = await validation.validateAsync(
removeNullishFields(values),
...args,
);
// https://github.com/react-hook-form/resolvers/blob/master/zod/src/zod.ts
return { values: data || {}, errors: errors || {}, ...rest };
},
mode: trigger, // Validate form (default: all)
}
: {
defaultValues: initialValues,
},
);

useEffect(() => {
if (validationTrigger === 'all-instant') {
methods.trigger();
}
}, []);

return (
<FormProvider {...methods} validation={validation}>
<div className="flex w-full flex-row justify-between gap-6">
<form
className={cn('flex-grow', className)}
data-testid={slugify(testId || name || '')}
name={name}
onSubmit={methods.handleSubmit(onSubmit)}
>
{children}
</form>
<FormDebugViewer className="w-96 flex-shrink" />
</div>
</FormProvider>
);
};

export default Form;
3 changes: 3 additions & 0 deletions packages/uniform/src/Form/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
import Form from './Form';

export default Form;
30 changes: 30 additions & 0 deletions packages/uniform/src/Form/subcomponents/FormContext.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
import type { VetoInstance } from '@fuf-stack/veto';
import type { FormProviderProps as HookFormProviderProps } from 'react-hook-form';

import React from 'react';
import { FormProvider as HookFormProvider } from 'react-hook-form';

export const ValidationSchemaContext = React.createContext<
VetoInstance | undefined
>(undefined);

interface FormProviderProps
extends HookFormProviderProps<Record<string, any>, any, undefined> {
/** veto validation schema */
validation?: VetoInstance;
}

/** Provides the veto validation context to the form */
const FormProvider = ({
children,
validation = undefined,
...hookFormProps
}: FormProviderProps) => {
return (
<ValidationSchemaContext.Provider value={validation}>
<HookFormProvider {...hookFormProps}>{children}</HookFormProvider>
</ValidationSchemaContext.Provider>
);
};

export default FormProvider;
88 changes: 88 additions & 0 deletions packages/uniform/src/Form/subcomponents/FormDebugViewer.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,88 @@
import type { VetoError } from '@fuf-stack/veto';

import { useEffect, useState } from 'react';
import { FaTimes } from 'react-icons/fa';
import { FaBug } from 'react-icons/fa6';

import cn from 'classnames';

import Button from '@fuf-stack/pixels/Button';
import Card from '@fuf-stack/pixels/Card';
import useLocalStorage from '@fuf-stack/pixels/hooks/useLocalStorage';
import Json from '@fuf-stack/pixels/Json';

import { useFormContext } from '../../hooks';

interface FormDebugViewerProps {
/** CSS class name */
className?: string;
}

const LOCALSTORAGE_DEBUG_KEY = 'uniform:form-debug-enabled';

/** Renders a form debug panel with information about the current form state */
const FormDebugViewer = ({ className = undefined }: FormDebugViewerProps) => {
const {
watch,
formState: { dirtyFields, isValid, isSubmitting },
validation,
} = useFormContext();

const [debug, setDebug] = useLocalStorage(LOCALSTORAGE_DEBUG_KEY, false);

const [validationErrors, setValidationErrors] = useState<
VetoError['errors'] | null
>(null);

const formValues = watch();

useEffect(() => {
const updateValidationErrors = async () => {
if (validation) {
const validateResult = await validation?.validateAsync(formValues);
setValidationErrors(validateResult?.errors);
}
};
updateValidationErrors();
}, [JSON.stringify(formValues)]);

if (!debug) {
return (
<Button
ariaLabel="Enable form debug mode"
onClick={() => setDebug(!debug)}
className="absolute bottom-2.5 right-2.5 w-5 text-default-400"
variant="light"
icon={<FaBug />}
/>
);
}

return (
<Card
className={cn(className)}
header={
<div className="flex w-full flex-row justify-between">
<span className="text-lg">Debug Mode</span>
<Button
icon={<FaTimes className="text-danger" />}
onClick={() => setDebug(false)}
size="sm"
variant="flat"
/>
</div>
}
>
<Json
value={{
values: formValues,
errors: validationErrors,
dirtyFields,
isValid,
isSubmitting,
}}
/>
</Card>
);
};
export default FormDebugViewer;
Loading

0 comments on commit c189b28

Please sign in to comment.