@kirill.konshin/utils 0.0.8 → 0.0.9

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (101) hide show
  1. package/.ctirc +10 -0
  2. package/.storybook/main.ts +21 -0
  3. package/.storybook/preview.ts +17 -0
  4. package/.turbo/turbo-build.log +67 -0
  5. package/.turbo/turbo-test.log +118 -0
  6. package/CHANGELOG.md +6 -0
  7. package/README.md +59 -0
  8. package/builders/.swcrc +18 -0
  9. package/builders/build.config.ts +11 -0
  10. package/builders/bun.build.js +23 -0
  11. package/builders/package.json +209 -0
  12. package/builders/perf.mjs +63 -0
  13. package/builders/rollup.config.mjs +50 -0
  14. package/builders/rslib.config.ts +26 -0
  15. package/builders/tsconfig.json +21 -0
  16. package/builders/tsup.config.ts +20 -0
  17. package/builders/turbo.json +24 -0
  18. package/builders/vite.config.ts +37 -0
  19. package/demo/cache-demo.ts +2 -1
  20. package/package.json +166 -22
  21. package/src/bootstrap/adaptiveContainer.tsx +12 -0
  22. package/src/bootstrap/controls.stories.tsx +62 -0
  23. package/src/bootstrap/controls.tsx +119 -0
  24. package/src/bootstrap/error.stories.tsx +34 -0
  25. package/src/bootstrap/error.tsx +40 -0
  26. package/src/bootstrap/field.stories.tsx +42 -0
  27. package/src/bootstrap/field.tsx +35 -0
  28. package/src/bootstrap/footer.tsx +49 -0
  29. package/src/bootstrap/globalLoading.stories.tsx +25 -0
  30. package/src/bootstrap/globalLoading.tsx +17 -0
  31. package/src/bootstrap/index.ts +12 -0
  32. package/src/bootstrap/loading.stories.tsx +35 -0
  33. package/src/bootstrap/loading.tsx +22 -0
  34. package/src/bootstrap/main.scss +86 -0
  35. package/src/bootstrap/responsiveHelper.tsx +50 -0
  36. package/src/bootstrap/screen.stories.tsx +160 -0
  37. package/src/bootstrap/screen.tsx +144 -0
  38. package/src/bootstrap/toaster.stories.tsx +32 -0
  39. package/src/bootstrap/toaster.tsx +38 -0
  40. package/src/bootstrap/useModal.stories.tsx +51 -0
  41. package/src/bootstrap/useModal.tsx +88 -0
  42. package/src/bootstrap/useWrappedForm.ts +40 -0
  43. package/src/core/cache.test.ts +121 -0
  44. package/src/electron/README.md +13 -0
  45. package/src/electron/createWindow.ts +234 -0
  46. package/src/electron/index.ts +2 -0
  47. package/src/electron/updater.ts +54 -0
  48. package/src/electron-builder/builder.ts +150 -0
  49. package/src/electron-builder/images.ts +33 -0
  50. package/src/electron-builder/index.ts +2 -0
  51. package/src/mui/README.md +3 -0
  52. package/src/mui/formControlFieldset.stories.tsx +71 -0
  53. package/src/mui/formControlFieldset.tsx +26 -0
  54. package/src/mui/formLabelLegend.tsx +11 -0
  55. package/src/mui/genericControl.tsx +20 -0
  56. package/src/mui/index.ts +4 -0
  57. package/src/mui/readOnly.tsx +6 -0
  58. package/src/next/appLink.tsx +39 -0
  59. package/src/next/index.ts +5 -0
  60. package/src/next/measure.ts +7 -0
  61. package/src/next/noSSR.tsx +17 -0
  62. package/src/next/redirect.tsx +13 -0
  63. package/src/next/useIsInner.ts +13 -0
  64. package/src/react/apiCall.ts +25 -0
  65. package/src/react/form/client.tsx +62 -0
  66. package/src/react/form/form.tsx +109 -0
  67. package/src/react/form/index.ts +2 -0
  68. package/src/react/index.ts +4 -0
  69. package/src/react/useFetch.ts +29 -0
  70. package/src/react/useFetcher.ts +49 -0
  71. package/src/react-native/index.ts +3 -0
  72. package/src/react-native/share.ts +31 -0
  73. package/src/react-native/update.tsx +40 -0
  74. package/src/react-native/useAppState.ts +18 -0
  75. package/src/tailwind/fullpage.tsx +11 -0
  76. package/src/tailwind/index.ts +2 -0
  77. package/src/tailwind/responsiveHelper.tsx +16 -0
  78. package/src-todo/auth0.tsx +177 -0
  79. package/tsconfig.json +15 -18
  80. package/turbo.json +2 -11
  81. package/vite.config.ts +47 -0
  82. package/vite.exports.ts +121 -0
  83. package/build/cache.d.ts +0 -120
  84. package/build/cache.js +0 -191
  85. package/build/errors.d.ts +0 -1
  86. package/build/errors.js +0 -14
  87. package/build/index.d.ts +0 -5
  88. package/build/index.js +0 -5
  89. package/build/measure.d.ts +0 -34
  90. package/build/measure.js +0 -48
  91. package/build/mutex.d.ts +0 -5
  92. package/build/mutex.js +0 -23
  93. package/build/worker.d.ts +0 -83
  94. package/build/worker.js +0 -250
  95. package/tsconfig.tsbuildinfo +0 -1
  96. package/src/{cache.ts → core/cache.ts} +0 -0
  97. package/src/{errors.ts → core/errors.ts} +0 -0
  98. package/src/{index.ts → core/index.ts} +1 -1
  99. /package/src/{measure.ts → core/measure.ts} +0 -0
  100. /package/src/{mutex.ts → core/mutex.ts} +0 -0
  101. /package/src/{worker.ts → core/worker.ts} +0 -0
@@ -0,0 +1,150 @@
1
+ import type { Configuration, MacConfiguration } from 'electron-builder';
2
+ import type { GithubOptions, S3Options } from 'builder-util-runtime';
3
+
4
+ const {
5
+ GITHUB_REF_TYPE,
6
+ GITHUB_REF_NAME,
7
+ GITHUB_RUN_ID,
8
+ GITHUB_REF,
9
+ AWS_URL,
10
+ AWS_ACCESS_KEY_ID,
11
+ AWS_SECRET_ACCESS_KEY,
12
+ GH_TOKEN,
13
+ CSC_IDENTITY_AUTO_DISCOVERY,
14
+ } = process.env;
15
+
16
+ const isGithubTag = GITHUB_REF_TYPE === 'tag';
17
+ const isGithubMaster = GITHUB_REF_TYPE === 'branch' && GITHUB_REF_NAME === 'master';
18
+ const isDevPublish = AWS_URL?.includes(':9000');
19
+ const isNoAutoDiscovery = CSC_IDENTITY_AUTO_DISCOVERY === 'false';
20
+ const isSkipPublish = !isDevPublish && !isGithubMaster && !isGithubTag;
21
+
22
+ //FIXME Refine logic, tags should be versions, not channels, use branches? See how Changesets does it
23
+ const channel = isGithubTag ? 'latest' : GITHUB_REF_NAME; // https://www.electron.build/tutorials/release-using-channels.html
24
+
25
+ function getVersion() {
26
+ if (isGithubMaster) {
27
+ return `0.0.${GITHUB_RUN_ID}`; //TODO Figure out last normal in master and bump it
28
+ }
29
+ if (isGithubTag) {
30
+ return GITHUB_REF!.split('/').pop();
31
+ }
32
+ return `0.0.${Date.now()}`; //TODO Read from package.json?
33
+ }
34
+
35
+ function getTarget() {
36
+ const arch = ['arm64', isNoAutoDiscovery ? '' : 'x64'].filter(Boolean) as any;
37
+
38
+ const target: MacConfiguration['target'] = [
39
+ {
40
+ target: 'dmg',
41
+ arch,
42
+ },
43
+ {
44
+ target: 'zip',
45
+ arch,
46
+ },
47
+ ];
48
+
49
+ // Leave only DMG if no signing
50
+ if (isNoAutoDiscovery) {
51
+ console.log('Skipping signing...');
52
+ delete process.env.CSC_LINK;
53
+ return [target[0]]; // only keep DMG
54
+ }
55
+
56
+ return target;
57
+ }
58
+
59
+ export function builerConfig(options: {
60
+ config: Configuration;
61
+ s3?: Pick<S3Options, 'bucket'> & Partial<S3Options>; //TODO Omit?
62
+ githubReleases: Pick<GithubOptions, 'owner' | 'repo'> & Partial<GithubOptions>;
63
+ }): Configuration {
64
+ if (options.s3 && (!AWS_URL || !AWS_ACCESS_KEY_ID || !AWS_SECRET_ACCESS_KEY)) {
65
+ throw new Error('Missing AWS configuration');
66
+ }
67
+
68
+ if (options.githubReleases && !GH_TOKEN) {
69
+ throw new Error('Missing AWS configuration');
70
+ }
71
+
72
+ const config: Configuration = {
73
+ files: [
74
+ '**/*',
75
+ '!electron-builder.js',
76
+ '!electron-builder.yml',
77
+ '!package.json',
78
+ 'assets',
79
+ ...(options.config.files as never),
80
+ ],
81
+ directories: {
82
+ buildResources: 'assets',
83
+ },
84
+ mac: {
85
+ category: options.config.mac?.category ?? 'public.app-category.utils',
86
+ target: getTarget(),
87
+ },
88
+ //TODO Windows target
89
+ dmg: {
90
+ contents: [
91
+ {
92
+ x: 130,
93
+ y: 130,
94
+ },
95
+ {
96
+ x: 370,
97
+ y: 130,
98
+ type: 'link',
99
+ path: '/Applications',
100
+ },
101
+ ],
102
+ },
103
+ publish: [
104
+ options.s3 &&
105
+ ({
106
+ provider: 's3',
107
+ endpoint: AWS_URL,
108
+ channel,
109
+ ...options.s3, // bucket
110
+ } as S3Options),
111
+ options.githubReleases &&
112
+ GH_TOKEN &&
113
+ (isGithubTag || isGithubMaster) &&
114
+ ({
115
+ provider: 'github',
116
+ private: true,
117
+ token: GH_TOKEN,
118
+ releaseType: isGithubTag ? 'release' : 'draft',
119
+ channel,
120
+ ...options.githubReleases, // repo, owner
121
+ } as GithubOptions),
122
+ ].filter(Boolean) as never,
123
+ extraMetadata: {
124
+ version: getVersion(),
125
+ },
126
+ };
127
+
128
+ if (isSkipPublish) {
129
+ delete config.publish;
130
+ delete (config as never as any).extraMetadata;
131
+ }
132
+
133
+ console.log('Electron Builder context', {
134
+ isDevPublish,
135
+ isGithubMaster,
136
+ isGithubTag,
137
+ isSkipPublish,
138
+ isNoAutoDiscovery,
139
+ channel,
140
+ GITHUB_RUN_ID: GITHUB_RUN_ID,
141
+ GITHUB_REF_TYPE: GITHUB_REF_TYPE,
142
+ GITHUB_REF_NAME: GITHUB_REF_NAME,
143
+ CSC_IDENTITY_AUTO_DISCOVERY: CSC_IDENTITY_AUTO_DISCOVERY,
144
+ AWS_URL: AWS_URL,
145
+ });
146
+
147
+ console.log('Electron Builder configuration', JSON.stringify(config, null, 2));
148
+
149
+ return config;
150
+ }
@@ -0,0 +1,33 @@
1
+ import psd from 'psd';
2
+ import sharp from 'sharp';
3
+ import path from 'node:path';
4
+
5
+ export async function generateImages({
6
+ iconPsdPath,
7
+ dmgBackgroundPsdPath,
8
+ electronAssetsPath,
9
+ webPublicPath,
10
+ }: {
11
+ iconPsdPath: string;
12
+ dmgBackgroundPsdPath: string;
13
+ electronAssetsPath: string;
14
+ webPublicPath: string;
15
+ }) {
16
+ const iconPsd = await psd.open(iconPsdPath);
17
+ await iconPsd.image!.saveAsPng(path.join(electronAssetsPath, 'icon.png'));
18
+
19
+ const bgPsd = await psd.open(dmgBackgroundPsdPath);
20
+ await bgPsd.image!.saveAsPng(path.join(electronAssetsPath, 'background@2x.png'));
21
+
22
+ const iconPng = sharp(path.join(electronAssetsPath, 'icon.png'));
23
+ const bgPng = sharp(path.join(electronAssetsPath, 'background@2x.png'));
24
+
25
+ await bgPng.resize(500).toFile(path.join(electronAssetsPath, 'background.png'));
26
+
27
+ await iconPng.toFile(path.join(electronAssetsPath, 'splash.webp'));
28
+ await iconPng.toFile(path.join(electronAssetsPath, 'icon.icns'));
29
+
30
+ //TODO Crop without icon BG
31
+ await iconPng.toFile(path.join(webPublicPath, 'icon.png'));
32
+ await iconPng.resize(32).toFile(path.join(webPublicPath, 'favicon.ico'));
33
+ }
@@ -0,0 +1,2 @@
1
+ export * from './builder';
2
+ export * from './images';
@@ -0,0 +1,3 @@
1
+ ## Prior Art
2
+
3
+ https://codesandbox.io/p/sandbox/l7vykxl57q MUI v4
@@ -0,0 +1,71 @@
1
+ import React from 'react';
2
+ import type { Meta, StoryObj } from '@storybook/react';
3
+
4
+ import { TextField, Stack, InputLabel, RadioGroup, Radio, FormControlLabel, Box } from '@mui/material';
5
+
6
+ import { GenericControl } from './genericControl';
7
+ import { ReadOnly } from './readOnly';
8
+ import { FormControlFieldset } from './formControlFieldset';
9
+ import { FormLabelLegend } from './formLabelLegend';
10
+
11
+ // More on how to set up stories at: https://storybook.js.org/docs/writing-stories#default-export
12
+ const meta = {
13
+ title: 'MUI / FormControlFieldset',
14
+ parameters: {
15
+ layout: 'centered',
16
+ },
17
+ tags: ['autodocs'],
18
+ argTypes: {},
19
+ render: function Render() {
20
+ return (
21
+ <Box sx={{ position: 'relative' }}>
22
+ <Rule top={12} />
23
+ <Rule top={37} />
24
+ <Rule top={48} />
25
+ <Stack direction="row" gap={3} sx={{ alignItems: 'flex-start' }}>
26
+ <FormControlFieldset>
27
+ <FormLabelLegend>Radios</FormLabelLegend>
28
+ <ReadOnly>
29
+ {/* TODO The size of the component. small is equivalent to the dense radio styling. */}
30
+ <RadioGroup row defaultValue="female" name="radio-buttons-group" sx={{ mt: -1 }}>
31
+ <FormControlLabel value="female" label="Female" control={<Radio size="small" />} />
32
+ <FormControlLabel value="male" label="Male" control={<Radio size="small" />} />
33
+ <FormControlLabel value="other" label="Other" control={<Radio size="small" />} />
34
+ </RadioGroup>
35
+ </ReadOnly>
36
+ </FormControlFieldset>
37
+
38
+ <GenericControl>
39
+ <InputLabel shrink>GenericControl</InputLabel>
40
+ <ReadOnly>
41
+ This is a <code>div</code>, not an input with <code>readOnly</code>
42
+ </ReadOnly>
43
+ </GenericControl>
44
+
45
+ <FormControlFieldset>
46
+ <FormLabelLegend>FormLabelLegend</FormLabelLegend>
47
+ <ReadOnly>
48
+ This is a <code>div</code>, not an input with <code>readOnly</code>
49
+ </ReadOnly>
50
+ </FormControlFieldset>
51
+
52
+ <TextField label="TextField" defaultValue="Bar" variant="standard" />
53
+ </Stack>
54
+ </Box>
55
+ );
56
+ },
57
+ } satisfies Meta<typeof GenericControl>;
58
+
59
+ export default meta;
60
+
61
+ type Story = StoryObj<typeof meta>;
62
+
63
+ function Rule({ top }) {
64
+ return (
65
+ <Box sx={{ background: 'red', position: 'absolute', left: 0, top: `${top}px`, width: '100%', height: '1px' }} />
66
+ );
67
+ }
68
+
69
+ export const Default: Story = {
70
+ args: {},
71
+ };
@@ -0,0 +1,26 @@
1
+ import * as React from 'react';
2
+ import { GenericControl, GenericControlProps } from './genericControl';
3
+ import { inputClasses } from '@mui/material/Input';
4
+
5
+ export type FormControlFieldsetProps = GenericControlProps;
6
+
7
+ export const FormControlFieldset: React.FC<FormControlFieldsetProps> = function FormControlFieldset({
8
+ children,
9
+ sx,
10
+ ...props
11
+ }) {
12
+ return (
13
+ <GenericControl
14
+ {...props}
15
+ component="fieldset"
16
+ sx={{
17
+ ...sx,
18
+ [`legend + .${inputClasses.root}`]: {
19
+ marginTop: 2, //FIXME 16px, hardcoded from @mui/material/Input/Input.js @ 59
20
+ },
21
+ }}
22
+ >
23
+ {children}
24
+ </GenericControl>
25
+ );
26
+ };
@@ -0,0 +1,11 @@
1
+ import * as React from 'react';
2
+ import { default as InputLabel, InputLabelProps } from '@mui/material/InputLabel';
3
+
4
+ export const FormLabelLegend: React.FC<InputLabelProps> = ({ children, ...props }) => {
5
+ return (
6
+ // sx={(theme) => theme.typography.caption}
7
+ <InputLabel {...props} shrink={true} component={'legend' as any}>
8
+ {children}
9
+ </InputLabel>
10
+ );
11
+ };
@@ -0,0 +1,20 @@
1
+ import * as React from 'react';
2
+ import { FormControlProps, default as FormControl } from '@mui/material/FormControl';
3
+ import { inputClasses } from '@mui/material/Input';
4
+
5
+ export type GenericControlProps = FormControlProps & { children: any };
6
+
7
+ export const GenericControl: React.FC<GenericControlProps> = ({ children, sx, ...props }) => (
8
+ <FormControl
9
+ variant="standard"
10
+ sx={{
11
+ ...sx,
12
+ [`& .${inputClasses.root}`]: {
13
+ '&:before, &:after': { display: 'none' },
14
+ },
15
+ }}
16
+ {...props}
17
+ >
18
+ {children}
19
+ </FormControl>
20
+ );
@@ -0,0 +1,4 @@
1
+ export * from './formControlFieldset';
2
+ export * from './formLabelLegend';
3
+ export * from './genericControl';
4
+ export * from './readOnly';
@@ -0,0 +1,6 @@
1
+ import React from 'react';
2
+ import { default as Input, InputProps } from '@mui/material/Input';
3
+
4
+ export const ReadOnly: React.FC<InputProps & { children: any }> = ({ children, inputProps, ...props }) => (
5
+ <Input {...props} inputComponent={'div' as any} inputProps={{ ...inputProps, children }}></Input>
6
+ );
@@ -0,0 +1,39 @@
1
+ 'use client';
2
+
3
+ import React, { ComponentProps, FC } from 'react';
4
+ import { usePathname } from 'next/navigation';
5
+ import Link from 'next/link';
6
+ import clsx from 'clsx';
7
+
8
+ //TODO Test this
9
+ export const AppLink: FC<
10
+ {
11
+ children: any;
12
+ exact?: boolean;
13
+ activeClassName?: any;
14
+ } & ComponentProps<typeof Link>
15
+ > = function AppLink({
16
+ href,
17
+ children,
18
+ className = '',
19
+ activeClassName = '',
20
+ prefetch = false, // set explicit default
21
+ exact = false,
22
+ ...props
23
+ }) {
24
+ const pathname = usePathname();
25
+ const hrefPath = typeof href === 'string' ? href : href.pathname || '/';
26
+
27
+ return (
28
+ <Link
29
+ {...props}
30
+ href={href}
31
+ className={clsx(className, {
32
+ [activeClassName]: (!exact && pathname.startsWith(hrefPath)) || (exact && pathname === hrefPath),
33
+ })}
34
+ prefetch={prefetch}
35
+ >
36
+ {children}
37
+ </Link>
38
+ );
39
+ };
@@ -0,0 +1,5 @@
1
+ export * from './appLink';
2
+ export * from './measure';
3
+ export * from './noSSR';
4
+ export * from './redirect';
5
+ export * from './useIsInner';
@@ -0,0 +1,7 @@
1
+ import { createMeasurer } from '../core/measure';
2
+ import { PHASE_PRODUCTION_BUILD, PHASE_PRODUCTION_SERVER } from 'next/constants';
3
+
4
+ export const isProd =
5
+ process.env.NEXT_PHASE === PHASE_PRODUCTION_BUILD || process.env.NEXT_PHASE === PHASE_PRODUCTION_SERVER;
6
+
7
+ export const measurer = createMeasurer({ colors: isProd });
@@ -0,0 +1,17 @@
1
+ 'use client';
2
+
3
+ import React, { useLayoutEffect, useState } from 'react';
4
+
5
+ const DefaultOnSSR = () => <span />;
6
+
7
+ // @see https://github.com/kadirahq/react-no-ssr/blob/master/src/index.js
8
+ // @see https://nextjs.org/docs/messages/react-hydration-error#solution-1-using-useeffect-to-run-on-the-client-only
9
+ export function NoSSR({ children, onSSR = <DefaultOnSSR /> }) {
10
+ const [canRender, setCanRender] = useState(false);
11
+
12
+ useLayoutEffect(() => {
13
+ setCanRender(true);
14
+ }, []);
15
+
16
+ return canRender ? children : onSSR;
17
+ }
@@ -0,0 +1,13 @@
1
+ 'use client';
2
+
3
+ import { useRouter } from 'next/navigation';
4
+ import { useEffect } from 'react';
5
+ import type { LinkProps } from 'next/link';
6
+
7
+ export default function Redirect({ to, replace }: { to: LinkProps['href']; replace?: LinkProps['replace'] }) {
8
+ const router = useRouter();
9
+ useEffect(() => {
10
+ router[replace ? 'replace' : 'push'](to.toString()); //TODO Test this
11
+ }, [replace, router, to]);
12
+ return null;
13
+ }
@@ -0,0 +1,13 @@
1
+ 'use client';
2
+
3
+ import { useSelectedLayoutSegments } from 'next/navigation';
4
+
5
+ export const useIsInner = (): boolean => {
6
+ const pathname = useSelectedLayoutSegments();
7
+ return pathname.length > 0;
8
+ };
9
+
10
+ export const useIsIndex = (): boolean => {
11
+ const pathname = useSelectedLayoutSegments();
12
+ return pathname.length === 0;
13
+ };
@@ -0,0 +1,25 @@
1
+ export const jsonContentType = 'application/json';
2
+
3
+ export type DataResponse<R> = Response & { data?: R };
4
+
5
+ export async function apiCall<R>(url: string, init?: RequestInit): Promise<DataResponse<R>> {
6
+ const useBodyAsIs = !init?.body || init?.body instanceof FormData || typeof init?.body === 'string';
7
+
8
+ const res: DataResponse<R> = await fetch(url, {
9
+ method: 'POST',
10
+ ...init,
11
+ body: useBodyAsIs ? init?.body : JSON.stringify(init.body),
12
+ headers: {
13
+ ...init?.headers,
14
+ ...(useBodyAsIs ? {} : { 'Content-Type': jsonContentType }),
15
+ },
16
+ });
17
+
18
+ if (res.headers.get('Content-Type')?.includes(jsonContentType)) {
19
+ res.data = await res.json();
20
+ }
21
+
22
+ if (!res.ok) throw new Error(res.statusText, { cause: res });
23
+
24
+ return res;
25
+ }
@@ -0,0 +1,62 @@
1
+ 'use client';
2
+
3
+ import { useActionState, useCallback, useState, useTransition } from 'react';
4
+ import { create, MaybeTypeOf, Validation } from './form';
5
+ import { z } from 'zod';
6
+
7
+ const FORM_DEBUG = process.env.NEXT_PUBLIC_FORM_DEBUG === 'true';
8
+
9
+ export function createClient<S extends z.ZodObject<any>>(schema: S) {
10
+ const { validate } = create(schema);
11
+
12
+ function useValidationCallback(
13
+ actionFn: (data: FormData) => Promise<Validation<S>>,
14
+ ): (formData: FormData) => Promise<Validation<S>> {
15
+ return useCallback(
16
+ async (formData: FormData) => {
17
+ const clientRes = validate(formData);
18
+ console.log('Client validation', FORM_DEBUG ? 'ignored' : 'active', clientRes);
19
+ if (!clientRes.success && !FORM_DEBUG) return clientRes;
20
+
21
+ const serverRes = await actionFn(formData);
22
+ console.log('Server validation', serverRes);
23
+ return serverRes;
24
+ },
25
+ [actionFn],
26
+ );
27
+ }
28
+
29
+ function useValidation(
30
+ actionFn: (data: FormData) => Promise<Validation<S>>,
31
+ initialData: MaybeTypeOf<S> = {} as MaybeTypeOf<S>,
32
+ ) {
33
+ const cb = useValidationCallback(actionFn);
34
+
35
+ return useActionState<Validation<S>, FormData>(async (_, data) => cb(data), {
36
+ success: false,
37
+ data: initialData,
38
+ });
39
+ }
40
+
41
+ function useValidationTransition(
42
+ actionFn: (data: FormData) => Promise<Validation<S>>,
43
+ initialData: MaybeTypeOf<S> = {} as MaybeTypeOf<S>,
44
+ ): [Validation<S>, (formData: FormData) => Promise<Validation<S>>, boolean] {
45
+ const [isPending, startTransition] = useTransition();
46
+ const [state, setState] = useState<Validation<S>>({ success: false, data: initialData });
47
+ const cb = useValidationCallback(actionFn);
48
+
49
+ const wrappedCb = useCallback(
50
+ (formData: FormData) => {
51
+ const promise = cb(formData);
52
+ startTransition(() => promise.then(setState));
53
+ return promise;
54
+ },
55
+ [cb, startTransition],
56
+ );
57
+
58
+ return [state, wrappedCb, isPending];
59
+ }
60
+
61
+ return { useValidation, useValidationTransition };
62
+ }
@@ -0,0 +1,109 @@
1
+ import { typeToFlattenedError, z, SafeParseReturnType, TypeOf } from 'zod';
2
+
3
+ const nonEmpty = 'This field cannot be empty';
4
+
5
+ export const stringRequired = () => z.string({ required_error: nonEmpty }).min(1, nonEmpty);
6
+ export const maxLength = (schema: z.ZodString): number => schema.maxLength || 0;
7
+ export const minLength = (schema: z.ZodString): number => schema.minLength || 0;
8
+ export const isRequired = (schema: z.ZodString): boolean => minLength(schema) > 0;
9
+
10
+ export type ZodObject = z.ZodObject<any> | z.ZodEffects<z.ZodObject<any>>; // z.ZodType<any, any, any>
11
+ export type MaybeTypeOf<S extends ZodObject> = Partial<TypeOf<S>>;
12
+ export type SafeTypeOf<S extends ZodObject> = SafeParseReturnType<TypeOf<S>, TypeOf<S>>['data'];
13
+ export type Errors<S extends ZodObject> = typeToFlattenedError<TypeOf<S>>['fieldErrors'];
14
+ export type Validation<S extends ZodObject> =
15
+ | {
16
+ success: true; // this is true only if form was validated successfully
17
+ data: SafeTypeOf<S>;
18
+ errors?: never;
19
+ }
20
+ | {
21
+ success: false;
22
+ data?: MaybeTypeOf<S>;
23
+ errors?: Errors<S>;
24
+ };
25
+
26
+ export function create<S extends ZodObject>(schema: S) {
27
+ const getShape = () =>
28
+ (schema as z.ZodObject<any>).shape || (schema as z.ZodEffects<z.ZodObject<any>>).sourceType().shape;
29
+
30
+ if (!getShape()) {
31
+ throw new Error('Invalid schema: only z.object() or z.object().refine() are supported');
32
+ }
33
+
34
+ function register(name: keyof TypeOf<S>, data?: MaybeTypeOf<S>, errors?: Errors<S>, mui: boolean = false) {
35
+ const field = getShape()[name];
36
+ return {
37
+ name,
38
+ id: name,
39
+ required: isRequired(field),
40
+ maxLength: maxLength(field),
41
+ type: field.isEmail ? 'email' : (name as string).includes('password') ? 'password' : 'text',
42
+ defaultValue: data?.[name],
43
+ ...(mui
44
+ ? {
45
+ label: field.description,
46
+ helperText: errors?.[name]?.join(', '),
47
+ error: !!errors?.[name]?.length,
48
+ }
49
+ : {}),
50
+ };
51
+ }
52
+
53
+ function validationError(data: MaybeTypeOf<S>, errors: Errors<S>): Validation<S> {
54
+ return {
55
+ success: false,
56
+ data, // data is undefined if there are errors
57
+ errors, // Next.js will butcher error object, so we provide something more primitive
58
+ };
59
+ }
60
+
61
+ function validate(formData: FormData): Validation<S> {
62
+ const rawData = Object.fromEntries(formData) as TypeOf<S>;
63
+ const { error, data } = schema.safeParse(rawData);
64
+
65
+ // console.log('Validate result', { error, data, rawData });
66
+
67
+ if (error) {
68
+ // data is undefined if there are errors
69
+ // Next.js will butcher error object, so we provide something more primitive
70
+ return validationError(rawData, error.flatten().fieldErrors as any);
71
+ }
72
+
73
+ return { success: true, data };
74
+ }
75
+
76
+ //FIXME Context?
77
+ function Field({
78
+ children,
79
+ name,
80
+ errors,
81
+ hint,
82
+ }: {
83
+ children?: any;
84
+ name: string;
85
+ errors?: Validation<S>['errors'];
86
+ hint?: string;
87
+ }) {
88
+ const { description } = getShape()[name];
89
+
90
+ return (
91
+ <div className="form-row">
92
+ {description && <label htmlFor={name}>{description}</label>}
93
+ {children}
94
+ {hint && <Hint>{hint}</Hint>}
95
+ {errors?.[name]?.map((e: string) => (
96
+ <Hint error key={e}>
97
+ {e}
98
+ </Hint>
99
+ ))}
100
+ </div>
101
+ );
102
+ }
103
+
104
+ return { register, validate, Field, validationError };
105
+ }
106
+
107
+ export function Hint({ children, error }: { children: any; error?: boolean }) {
108
+ return <div className={`hint ${error ? 'hint-error' : ''}`}>{children}</div>;
109
+ }
@@ -0,0 +1,2 @@
1
+ export * from './client';
2
+ export * from './form';
@@ -0,0 +1,4 @@
1
+ export * from './form';
2
+ export * from './apiCall';
3
+ export * from './useFetch';
4
+ export * from './useFetcher';