Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

1.11.0 #173

Merged
merged 7 commits into from
Feb 11, 2025
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 5 additions & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,11 +1,15 @@
## 1.11.0
`feature`: `FieldVisibility` now accepts the `children` prop to allow a custom dropdown button.
`feature`: `EnumInput` can now be passed options of of type `SelectOption | string` for more customizability.
`feature`: Enhanced the way to customize actions for `ModelForm` and `ModelTable`. Can alter the underlying button by the `actionOptions.actionProps` prop or by expanding the model component down to its primitive components: `EditAction, CancelEditAction, SubmitAction, DeleteAction`.

## 1.10.2
`bugfix`: Fixed delay on form values populating `FormDisplay`.
`upgrade`: [email protected]

## 1.10.1
`bugfix`: z-index of select components were using `react-select`'s default z-index and not the override version.


## 1.10.0
- `feature`: Can now set which rows are "selected" to trigger its selected-row background color.
- `bugfix`: Table resizing now does not allow you to go less than the content width for a brief moment
Expand Down
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
{
"name": "@autoinvent/conveyor",
"type": "module",
"version": "1.10.2",
"version": "1.11.0",
"description": "UI component library for magql",
"license": "BlueOak-1.0.0",
"author": "Moebius Solutions",
Expand Down
75 changes: 75 additions & 0 deletions src/Actions/ActionContext.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,75 @@
import {
type ComponentProps,
type ReactNode,
createContext,
useEffect,
useRef,
useState,
} from 'react';

import { type StoreApi, createStore } from 'zustand';

import type { Button } from '@/lib/components/ui/button';
import type { DataType } from '@/types';

export enum Action {
SUBMIT = 'SUBMIT',
DELETE = 'DELETE',
EDIT = 'EDIT',
CANCEL_EDIT = 'CANCEL_EDIT',
}

export type OnActionTrigger<TParams, TReturn> =
| ((params: TParams) => Promise<TReturn>)
| ((params: TParams) => void);

export interface ActionParams<D extends DataType> {
data: D;
changedData: D;
onEdit: () => void;
onCancelEdit: () => void;
}

export type ActionsType<D extends DataType> = Partial<
Record<Action, OnActionTrigger<ActionParams<D>, void> | null>
>;

export type ActionsPropsType = Partial<
Record<Action, ComponentProps<typeof Button>>
>;

export interface ActionState<D extends DataType> {
showActions?: boolean;
actions?: ActionsType<D>;
actionProps?: ActionsPropsType;
}

export const ActionStoreContext = createContext<
StoreApi<ActionState<any>> | undefined
>(undefined);

export type ActionStoreProviderProps<D extends DataType> = ActionState<D> & {
children?: ReactNode;
};

export const ActionStoreProvider = <D extends DataType>({
children,
...actionState
}: ActionStoreProviderProps<D>) => {
const isMounted = useRef(false);
const [store] = useState(() => createStore(() => actionState));
/*
biome-ignore lint/correctness/useExhaustiveDependencies:
The reference to tableState does not matter, only the contents.
*/
useEffect(() => {
if (isMounted.current) store.setState(actionState);
else isMounted.current = true;
}, [...Object.values(actionState), store]);

return (
<ActionStoreContext.Provider value={store}>
{children}
</ActionStoreContext.Provider>
);
};
40 changes: 40 additions & 0 deletions src/Actions/CancelEditAction.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
import type { ComponentProps } from 'react';

import { X } from 'lucide-react';

import { Button } from '@/lib/components/ui/button';

import { Action } from './ActionContext';
import { useActionStore } from './useActionStore';
import { useGetActionParams } from './useGetActionParams';

export interface CancelEditActionProps extends ComponentProps<typeof Button> {}

export const CancelEditAction = ({
size,
variant = size === 'icon' ? 'ghost' : 'outline',
children = size === 'icon' ? <X className="h-4 w-4" /> : 'Cancel',
...buttonProps
}: CancelEditActionProps) => {
const onCancelEditProp = useActionStore(
(state) => state.actions?.[Action.CANCEL_EDIT],
);
const getActionParams = useGetActionParams();
const { onCancelEdit } = getActionParams({});
const onCancelEditHandler =
onCancelEditProp === undefined ? onCancelEdit : onCancelEditProp;

return (
onCancelEditHandler && (
<Button
variant={variant}
size={size}
onClick={onCancelEdit}
onKeyUp={(e) => e.key === 'Enter' && onCancelEdit()}
{...buttonProps}
>
{children}
</Button>
)
);
};
41 changes: 41 additions & 0 deletions src/Actions/DeleteAction.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
import type { ComponentProps } from 'react';

import { Trash2 } from 'lucide-react';

import { useFormStore } from '@/Form';
import { Button } from '@/lib/components/ui/button';

import { Action } from './ActionContext';
import { useActionStore } from './useActionStore';
import { useGetActionParams } from './useGetActionParams';

export interface DeleteActionProps extends ComponentProps<typeof Button> {}

export const DeleteAction = ({
size,
variant = size === 'icon' ? 'ghost-destructive' : 'destructive',
children = size === 'icon' ? <Trash2 className="h-4 w-4" /> : 'Delete',
...buttonProps
}: DeleteActionProps) => {
const getActionParams = useGetActionParams();
const handleSubmit = useFormStore((state) => state.handleSubmit);
const onDelete = useActionStore((state) => state.actions?.[Action.DELETE]);

const onDeleteHandler = handleSubmit(async () => {
await onDelete?.(getActionParams({}));
});

return (
onDelete && (
<Button
variant={variant}
size={size}
onClick={onDeleteHandler}
onKeyUp={(e) => e.key === 'Enter' && onDeleteHandler()}
{...buttonProps}
>
{children}
</Button>
)
);
};
37 changes: 37 additions & 0 deletions src/Actions/EditAction.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
import type { ComponentProps } from 'react';

import { SquarePen } from 'lucide-react';

import { Button } from '@/lib/components/ui/button';

import { Action } from './ActionContext';
import { useActionStore } from './useActionStore';
import { useGetActionParams } from './useGetActionParams';

export interface EditActionProps extends ComponentProps<typeof Button> {}

export const EditAction = ({
size,
variant = size === 'icon' ? 'ghost' : 'default',
children = size === 'icon' ? <SquarePen className="h-4 w-4" /> : 'Edit',
...buttonProps
}: EditActionProps) => {
const onEditProp = useActionStore((state) => state.actions?.[Action.EDIT]);
const getActionParams = useGetActionParams();
const { onEdit } = getActionParams({});
const onEditHandler = onEditProp === undefined ? onEdit : onEditProp;

return (
onEditHandler && (
<Button
variant={variant}
size={size}
onClick={onEdit}
onKeyUp={(e) => e.key === 'Enter' && onEdit()}
{...buttonProps}
>
{children}
</Button>
)
);
};
46 changes: 46 additions & 0 deletions src/Actions/SubmitAction.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
import type { ComponentProps } from 'react';

import { Save } from 'lucide-react';

import { useFormStore } from '@/Form';
import { Button } from '@/lib/components/ui/button';
import type { DataType } from '@/types';

import { Action } from './ActionContext';
import { useActionStore } from './useActionStore';
import { useGetActionParams } from './useGetActionParams';

export interface SubmitActionProps extends ComponentProps<typeof Button> {}

export const SubmitAction = ({
size,
variant = size === 'icon' ? 'ghost-success' : 'default',
children = size === 'icon' ? <Save className="h-4 w-4" /> : 'Save',
...buttonProps
}: SubmitActionProps) => {
const getActionParams = useGetActionParams();
const handleSubmit = useFormStore((state) => state.handleSubmit);
const onSubmit = useActionStore((state) => state.actions?.[Action.SUBMIT]);
const updateProps = useActionStore(
(state) => state.actionProps?.[Action.SUBMIT],
);

const onSubmitHandler = handleSubmit(async (formData: DataType) => {
await onSubmit?.(getActionParams(formData));
});

return (
onSubmit && (
<Button
variant={variant}
size={size}
onClick={onSubmitHandler}
onKeyUp={(e) => e.key === 'Enter' && onSubmitHandler()}
{...updateProps}
{...buttonProps}
>
{updateProps?.children ?? children}
</Button>
)
);
};
27 changes: 27 additions & 0 deletions src/Actions/useActionStore.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
import { useContext } from 'react';

import { useStore } from 'zustand';

import type { DataType, StoreSelector } from '@/types';

import { type ActionState, ActionStoreContext } from './ActionContext';

export function useActionStore<D extends DataType>(): ActionState<D>;
export function useActionStore<D extends DataType, T>(
selector: StoreSelector<ActionState<D>, T>,
): T;

export function useActionStore<D extends DataType, T>(
selector?: StoreSelector<ActionState<D>, T>,
) {
const actionStore = useContext(ActionStoreContext);
if (actionStore === undefined) {
throw new Error('useActionStore must be used within ActionStoreProvider');
}

const selected = selector
? useStore(actionStore, selector)
: useStore(actionStore);

return selected;
}
34 changes: 34 additions & 0 deletions src/Actions/useGetActionParams.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
import { useFormStore } from '@/Form';
import { useLensesStore } from '@/Lenses';
import { DataLens, type DataType } from '@/types';

import type { ActionParams } from './ActionContext';

export const useGetActionParams = <D extends DataType>(): ((
formValues: D,
) => ActionParams<D>) => {
const setLens = useLensesStore((state) => state.setLens);

const defaultValues = useFormStore(
(state) => state.formState.defaultValues as D,
);
const dirtyFields = useFormStore((state) => state.formState.dirtyFields);
const reset = useFormStore((state) => state.reset);

const getChangedData = (formValues: D) =>
Object.fromEntries(
Object.entries(formValues).filter((entry) => dirtyFields[entry[0]]),
) as D;
const onEdit = () => setLens(DataLens.INPUT);
const onCancelEdit = () => {
setLens(DataLens.DISPLAY);
reset();
};

return (formValues) => ({
data: { ...defaultValues },
changedData: getChangedData(formValues),
onEdit,
onCancelEdit,
});
};
10 changes: 6 additions & 4 deletions src/BasicInputs/EnumInput.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,19 +4,19 @@ import {
forwardRef,
} from 'react';

import { humanizeText } from '@/utils';
import type { SelectOption } from '@/types';

import { SelectInput } from './SelectInput';

export const EnumInput = forwardRef<
ElementRef<typeof SelectInput>,
Omit<ComponentPropsWithoutRef<typeof SelectInput>, 'value' | 'options'> & {
value?: string | string[];
options: string[];
options: (string | SelectOption)[];
}
>(({ value, onChange, options, ...selectInputProps }, ref) => {
const stringToOption = (str: string) => ({
label: humanizeText(str),
label: str,
value: str,
});
return (
Expand All @@ -28,7 +28,9 @@ export const EnumInput = forwardRef<
? value.map(stringToOption)
: stringToOption(value))
}
options={options?.map(stringToOption)}
options={options?.map((option) =>
typeof option === 'string' ? stringToOption(option) : option,
)}
onChange={(newValue, actionMeta) =>
onChange?.(
Array.isArray(newValue)
Expand Down
9 changes: 9 additions & 0 deletions src/BasicInputs/stories/EnumInput.stories.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -51,3 +51,12 @@ export const MultiSelectWithValueInference: Story = {
value: [],
},
};

export const CustomOptions: Story = {
args: {
options: [
{ label: <span className="bg-red-400">APPLE</span>, value: 'apple' },
'banana',
],
},
};
Loading