From c57d7701762ff363b67452af63eabea319498d84 Mon Sep 17 00:00:00 2001 From: Pierantonio Zocchi Date: Wed, 14 Jan 2026 16:09:15 +0100 Subject: [PATCH] Add Crud examples on Storybook and minor fix on RLCrud, RLDialog and RLCrudFilters --- src/components/RLCrud/RLCrud.tsx | 18 +- .../RLCrudFilters/RLCrudFilters.tsx | 3 +- src/components/RLDialog/RLDialog.tsx | 2 +- src/components/examples/UsersCrudExample.tsx | 227 ++++++++++++++++++ .../examples/actions/ActionDelete.tsx | 21 ++ .../examples/actions/ActionEdit.tsx | 21 ++ src/components/examples/cells/ActiveCell.tsx | 27 +++ src/components/examples/cells/DateCell.tsx | 23 ++ .../examples/dialogs/DeleteDialog.tsx | 48 ++++ .../examples/dialogs/TestDialog.tsx | 16 ++ src/components/examples/index.ts | 11 + src/components/examples/stores/usersStore.ts | 158 ++++++++++++ src/components/examples/types/user.ts | 11 + .../examples/UsersCrudExample.stories.tsx | 16 ++ 14 files changed, 594 insertions(+), 8 deletions(-) create mode 100644 src/components/examples/UsersCrudExample.tsx create mode 100644 src/components/examples/actions/ActionDelete.tsx create mode 100644 src/components/examples/actions/ActionEdit.tsx create mode 100644 src/components/examples/cells/ActiveCell.tsx create mode 100644 src/components/examples/cells/DateCell.tsx create mode 100644 src/components/examples/dialogs/DeleteDialog.tsx create mode 100644 src/components/examples/dialogs/TestDialog.tsx create mode 100644 src/components/examples/index.ts create mode 100644 src/components/examples/stores/usersStore.ts create mode 100644 src/components/examples/types/user.ts create mode 100644 src/stories/examples/UsersCrudExample.stories.tsx diff --git a/src/components/RLCrud/RLCrud.tsx b/src/components/RLCrud/RLCrud.tsx index c99835e..e9102bd 100644 --- a/src/components/RLCrud/RLCrud.tsx +++ b/src/components/RLCrud/RLCrud.tsx @@ -64,7 +64,15 @@ export const RLCrud = forwardRef( : rowsPerPageOptions[0] ) const [totalRows, setTotalRows] = useState(0) - const [filtersApplied, setFiltersApplied] = useState>({}) + const [filtersApplied, setFiltersApplied] = useState>(() => + filtersConfig.reduce( + (acc, filter) => ({ + ...acc, + [filter.value]: filter.default_value + }), + {} + ) + ) const [showDialog, setShowDialog] = useState(false) const [dialog, setDialog] = useState(null) const [dialogProps, setDialogProps] = useState>({}) @@ -169,7 +177,7 @@ export const RLCrud = forwardRef( const onFiltersApplied = useCallback((appliedFilters: Record) => { setFiltersApplied(appliedFilters) - setCurrentPage(1) + setCurrentPage(0) }, []) const onConfirm = useCallback(async () => { @@ -207,7 +215,7 @@ export const RLCrud = forwardRef( setFiltersApplied({ [primary_key]: newId as RLCrudInputValueType }) filtersRef.current?.setFilterModel({ [primary_key]: newId as RLCrudInputValueType }) filtersRef.current?.setOpen(true) - setCurrentPage(1) + setCurrentPage(0) await Promise.resolve() // Allow state to update skipWatchersRef.current = false } @@ -318,8 +326,8 @@ export const RLCrud = forwardRef( {bottomContainerSlot} setCurrentPage(page - 1)} rowsPerPage={rowsPerPage} onRowsPerPageChange={setRowsPerPage} totalRows={totalRows} diff --git a/src/components/RLCrudFilters/RLCrudFilters.tsx b/src/components/RLCrudFilters/RLCrudFilters.tsx index c60940f..e6825c5 100644 --- a/src/components/RLCrudFilters/RLCrudFilters.tsx +++ b/src/components/RLCrudFilters/RLCrudFilters.tsx @@ -46,8 +46,7 @@ export const RLCrudFilters = forwardRef( // Initialize on mount useEffect(() => { - const initialFilters = resetFields() - onFiltersApplied?.(initialFilters) + resetFields() }, []) // eslint-disable-line react-hooks/exhaustive-deps const handleApply = useCallback(() => { diff --git a/src/components/RLDialog/RLDialog.tsx b/src/components/RLDialog/RLDialog.tsx index 96a0607..ee4de79 100644 --- a/src/components/RLDialog/RLDialog.tsx +++ b/src/components/RLDialog/RLDialog.tsx @@ -84,7 +84,7 @@ export const RLDialog = forwardRef( = { + id: 'users', + singular_label: 'user', + primary_key: 'id', + filters_title: 'filters', + headers: [ + { + i18n_key: 'Username', + sortable: false, + value: 'username', + columnProps: { + className: 'w-1/4' + } + }, + { + i18n_key: 'First name', + sortable: false, + value: 'firstName' + }, + { + i18n_key: 'Last name', + sortable: false, + value: 'lastName' + }, + { + i18n_key: 'Role', + sortable: false, + value: 'role' + }, + { + i18n_key: 'Age', + sortable: false, + value: 'age' + }, + { + i18n_key: 'Active', + sortable: false, + value: 'active', + type: 'boolean', + componentProps: { + trueColor: 'text-success-500' + } + }, + { + i18n_key: 'Activation date', + value: 'activation_date', + sortable: false, + type: 'date' + }, + { + i18n_key: 'Expiration date', + value: 'expiration_date', + sortable: false, + type: 'date' + }, + { + i18n_key: 'Description', + value: 'description' + } + ], + filters: [ + { + i18n_key: 'Username', + value: 'username', + input_type: 'text' + }, + { + i18n_key: 'First name', + value: 'firstName', + input_type: 'text' + }, + { + i18n_key: 'Last name', + value: 'lastName', + input_type: 'text' + }, + { + i18n_key: 'Role', + value: 'role', + input_type: 'select', + options: [ + { value: '', text: '' }, + { value: 'admin', text: 'admin' }, + { value: 'user', text: 'user' }, + { value: 'guest', text: 'guest', icon: 'ghost' } + ], + default_value: '' + }, + { + i18n_key: 'Activation date', + value: 'activation_date', + input_type: 'date' + } + ], + form_fields: [ + { + i18n_key: 'Username', + value: 'username', + placeholder: 'Enter username', + required: true, + rules: [ + { validateFn: (v: unknown) => !!(v as string), message: 'Username is required' }, + { + validateFn: (v: unknown) => (v as string).length > 3, + message: 'Username must be at least 4 characters long' + } + ], + side_effect: (model, fields) => { + const { username } = model as { username: string } + if (username === 'admin') { + fields.role.options = [{ value: 'admin', text: 'admin' }] + ;(model as { role: string }).role = 'admin' + } else { + fields.role.options = [ + { value: '', text: '' }, + { value: 'admin', text: 'admin' }, + { value: 'user', text: 'user' }, + { value: 'guest', text: 'guest' } + ] + } + }, + input_type: 'text' + }, + { + i18n_key: 'First name', + value: 'firstName', + input_type: 'text' + }, + { + i18n_key: 'Last name', + value: 'lastName', + input_type: 'text' + }, + { + i18n_key: 'Active', + value: 'active', + input_type: 'checkbox', + default_value: true + }, + { + i18n_key: 'Role', + value: 'role', + input_type: 'select', + options: [ + { value: '', text: '' }, + { value: 'admin', text: 'admin' }, + { value: 'user', text: 'user' }, + { value: 'guest', text: 'guest' } + ] + }, + { + i18n_key: 'Age', + value: 'age', + input_type: 'number' + }, + { + i18n_key: 'Activation date', + value: 'activation_date', + input_type: 'date' + }, + { + i18n_key: 'Expiration date', + value: 'expiration_date', + input_type: 'date' + }, + { + i18n_key: 'Description', + value: 'description', + input_type: 'textarea' + } + ], + actions: [ + { + name: 'Delete', + i18n_key: 'Delete', + icon_name: 'delete', + onClick: (data: unknown) => { + console.log('Delete side effect', { ...data as object }) + }, + component: DeleteDialog, + dialogProperties: { + noCloseOnOutsideClick: false + } + } + ] +} + +export const UsersCrudExample = () => { + return ( + , + date: DateCell as ComponentType + }} + actionHeaderI18nKey="Actions" + addI18nKey="Add" + applyI18nKey="Apply" + resetI18nKey="Reset" + cancelI18nKey="Cancel" + addButtonI18nKey="Add user" + addTitleI18nKey="Add user" + editTitleI18nKey="Edit user" + editTooltipI18nKey="Edit" + /> + ) +} + +UsersCrudExample.displayName = 'UsersCrudExample' diff --git a/src/components/examples/actions/ActionDelete.tsx b/src/components/examples/actions/ActionDelete.tsx new file mode 100644 index 0000000..b7c2d9c --- /dev/null +++ b/src/components/examples/actions/ActionDelete.tsx @@ -0,0 +1,21 @@ +import { RLIcon } from '../../RLIcon' +import { addIcon } from '../../../icons' + +import deleteCircle from '@mdi/svg/svg/delete-circle.svg' + +addIcon('deleteCircle', deleteCircle) + +interface ActionDeleteProps { + data: unknown +} + +export const ActionDelete = (_props: ActionDeleteProps) => { + return ( + + ) +} + +ActionDelete.displayName = 'ActionDelete' diff --git a/src/components/examples/actions/ActionEdit.tsx b/src/components/examples/actions/ActionEdit.tsx new file mode 100644 index 0000000..8839a2b --- /dev/null +++ b/src/components/examples/actions/ActionEdit.tsx @@ -0,0 +1,21 @@ +import { RLIcon } from '../../RLIcon' +import { addIcon } from '../../../icons' + +import pencilCircle from '@mdi/svg/svg/pencil-circle.svg' + +addIcon('pencilCircle', pencilCircle) + +interface ActionEditProps { + data: unknown +} + +export const ActionEdit = (_props: ActionEditProps) => { + return ( + + ) +} + +ActionEdit.displayName = 'ActionEdit' diff --git a/src/components/examples/cells/ActiveCell.tsx b/src/components/examples/cells/ActiveCell.tsx new file mode 100644 index 0000000..5f8023d --- /dev/null +++ b/src/components/examples/cells/ActiveCell.tsx @@ -0,0 +1,27 @@ +import { RLIcon } from '../../RLIcon' +import { addIcon } from '../../../icons' + +import checkCircle from '@mdi/svg/svg/check-circle.svg' +import closeCircle from '@mdi/svg/svg/close-circle.svg' + +addIcon('checkCircle', checkCircle) +addIcon('closeCircle', closeCircle) + +interface ActiveCellProps { + data?: unknown + trueColor?: string +} + +export const ActiveCell = ({ data, trueColor = 'text-green-500' }: ActiveCellProps) => { + const typedData = data as { active?: boolean } | undefined + const isActive = typedData?.active + + return ( + + ) +} + +ActiveCell.displayName = 'ActiveCell' diff --git a/src/components/examples/cells/DateCell.tsx b/src/components/examples/cells/DateCell.tsx new file mode 100644 index 0000000..62a1587 --- /dev/null +++ b/src/components/examples/cells/DateCell.tsx @@ -0,0 +1,23 @@ +interface DateCellProps { + data?: unknown + field?: string +} + +export const DateCell = ({ data, field }: DateCellProps) => { + if (!data || !field) { + return
+ } + + const typedData = data as { [key: string]: unknown } + const dateValue = typedData[field] as Date | undefined + + if (!dateValue || !(dateValue instanceof Date)) { + return
+ } + + const formattedDate = `${dateValue.getFullYear()}-${('0' + (dateValue.getMonth() + 1)).slice(-2)}-${('0' + dateValue.getDate()).slice(-2)}` + + return
{formattedDate}
+} + +DateCell.displayName = 'DateCell' diff --git a/src/components/examples/dialogs/DeleteDialog.tsx b/src/components/examples/dialogs/DeleteDialog.tsx new file mode 100644 index 0000000..28b1283 --- /dev/null +++ b/src/components/examples/dialogs/DeleteDialog.tsx @@ -0,0 +1,48 @@ +import { RLButton } from '../../RLButton' +import { usersStore } from '../stores/usersStore' + +interface DeleteDialogProps { + data: { + id: string + item: unknown + primary_key: string + [key: string]: unknown + } + onClose?: () => void + onConfirm?: () => void + onFetchOnClose?: () => void +} + +export const DeleteDialog = ({ data, onClose, onConfirm, onFetchOnClose }: DeleteDialogProps) => { + const typedItem = data?.item as { username?: string; [key: string]: unknown } | undefined + + const handleCancel = () => { + onClose?.() + } + + const handleConfirm = async () => { + if (typedItem) { + await usersStore.deleteUser(typedItem[data.primary_key] as string) + } + onConfirm?.() + onFetchOnClose?.() + onClose?.() + } + + return ( + <> +
Delete - {typedItem?.username}
+
+ Do you want to delete the user? +
+ Cancel + + Confirm + +
+
+ + ) +} + +DeleteDialog.displayName = 'DeleteDialog' diff --git a/src/components/examples/dialogs/TestDialog.tsx b/src/components/examples/dialogs/TestDialog.tsx new file mode 100644 index 0000000..59d474f --- /dev/null +++ b/src/components/examples/dialogs/TestDialog.tsx @@ -0,0 +1,16 @@ +import { RLDialog } from '../../RLDialog' + +interface TestDialogProps { + open: boolean + onOpenChange: (open: boolean) => void +} + +export const TestDialog = ({ open, onOpenChange }: TestDialogProps) => { + return ( + +
from Skillbill!
+
+ ) +} + +TestDialog.displayName = 'TestDialog' diff --git a/src/components/examples/index.ts b/src/components/examples/index.ts new file mode 100644 index 0000000..a3b4f6c --- /dev/null +++ b/src/components/examples/index.ts @@ -0,0 +1,11 @@ +export { UsersCrudExample } from './UsersCrudExample' + +export { ActiveCell } from './cells/ActiveCell' +export { DateCell } from './cells/DateCell' + +export { ActionDelete } from './actions/ActionDelete' +export { ActionEdit } from './actions/ActionEdit' +export { DeleteDialog } from './dialogs/DeleteDialog' + +export { usersStore } from './stores/usersStore' +export type { User } from './types/user' diff --git a/src/components/examples/stores/usersStore.ts b/src/components/examples/stores/usersStore.ts new file mode 100644 index 0000000..46d5ac9 --- /dev/null +++ b/src/components/examples/stores/usersStore.ts @@ -0,0 +1,158 @@ +import type { User } from '../types/user' + +const mockUsers: User[] = [ + { + id: '1', + username: 'john_doe', + firstName: 'John', + lastName: 'Doe', + active: true, + role: 'admin', + age: 30, + activation_date: new Date('2023-01-15'), + expiration_date: new Date('2025-01-15') + }, + { + id: '2', + username: 'jane_smith', + firstName: 'Jane', + lastName: 'Smith', + active: true, + role: 'user', + age: 28, + activation_date: new Date('2023-03-20'), + expiration_date: new Date('2025-03-20') + }, + { + id: '3', + username: 'bob_wilson', + firstName: 'Bob', + lastName: 'Wilson', + active: false, + role: 'guest', + age: 45, + activation_date: new Date('2022-06-10'), + expiration_date: new Date('2024-06-10') + }, + { + id: '4', + username: 'alice_jones', + firstName: 'Alice', + lastName: 'Jones', + active: true, + role: 'user', + age: 32, + activation_date: new Date('2023-09-01'), + expiration_date: new Date('2025-09-01') + }, + { + id: '5', + username: 'charlie_brown', + firstName: 'Charlie', + lastName: 'Brown', + active: true, + role: 'admin', + age: 38, + activation_date: new Date('2026-12-05'), + expiration_date: new Date('2027-12-05') + } +] + +let users = [...mockUsers] + +export const usersStore = { + getUsers: async ( + page: number, + rowsPerPage: number, + filters: unknown + ) => { + const typedFilters = (filters || {}) as Record + // Simulate API delay + await new Promise((resolve) => setTimeout(resolve, 300)) + + // Apply filters + let filteredUsers = [...users] + + if (typedFilters.username) { + filteredUsers = filteredUsers.filter((u) => + u.username.toLowerCase().includes((typedFilters.username as string).toLowerCase()) + ) + } + if (typedFilters.firstName) { + filteredUsers = filteredUsers.filter((u) => + u.firstName.toLowerCase().includes((typedFilters.firstName as string).toLowerCase()) + ) + } + if (typedFilters.lastName) { + filteredUsers = filteredUsers.filter((u) => + u.lastName.toLowerCase().includes((typedFilters.lastName as string).toLowerCase()) + ) + } + if (typedFilters.role) { + filteredUsers = filteredUsers.filter((u) => u.role === typedFilters.role) + } + if (typedFilters.activation_date) { + const filterDate = new Date(typedFilters.activation_date as string | Date) + filteredUsers = filteredUsers.filter((u) => { + if (!u.activation_date) return false + const userDate = new Date(u.activation_date) + return ( + userDate.getFullYear() === filterDate.getFullYear() && + userDate.getMonth() === filterDate.getMonth() && + userDate.getDate() === filterDate.getDate() + ) + }) + } + + const startIndex = page * rowsPerPage + const paginatedUsers = filteredUsers.slice(startIndex, startIndex + rowsPerPage) + + return { + result: paginatedUsers, + page: { + currentPage: page, + pageRows: rowsPerPage, + totalRows: filteredUsers.length + } + } + }, + + createUser: async (item: unknown) => { + const user = item as Partial + await new Promise((resolve) => setTimeout(resolve, 300)) + const newUser: User = { + id: String(Date.now()), + username: user.username || '', + firstName: user.firstName || '', + lastName: user.lastName || '', + active: user.active ?? false, + role: user.role, + age: user.age, + activation_date: user.activation_date, + expiration_date: user.expiration_date + } + users.push(newUser) + return newUser + }, + + updateUser: async (item: unknown) => { + const user = item as Partial + await new Promise((resolve) => setTimeout(resolve, 300)) + const index = users.findIndex((u) => u.id === user.id) + if (index !== -1) { + users[index] = { ...users[index], ...user } + return users[index] + } + return null + }, + + deleteUser: async (id: string) => { + await new Promise((resolve) => setTimeout(resolve, 300)) + users = users.filter((u) => u.id !== id) + return true + }, + + resetUsers: () => { + users = [...mockUsers] + } +} diff --git a/src/components/examples/types/user.ts b/src/components/examples/types/user.ts new file mode 100644 index 0000000..bfc88b8 --- /dev/null +++ b/src/components/examples/types/user.ts @@ -0,0 +1,11 @@ +export interface User { + id: string + username: string + firstName: string + lastName: string + active: boolean + role?: string + age?: number + activation_date?: Date + expiration_date?: Date +} diff --git a/src/stories/examples/UsersCrudExample.stories.tsx b/src/stories/examples/UsersCrudExample.stories.tsx new file mode 100644 index 0000000..552b456 --- /dev/null +++ b/src/stories/examples/UsersCrudExample.stories.tsx @@ -0,0 +1,16 @@ +import type { Meta, StoryObj } from '@storybook/react' +import { UsersCrudExample } from '../../components/examples' + +const meta = { + title: 'Examples/UsersCrud', + component: UsersCrudExample, + tags: ['autodocs'], + parameters: { + layout: 'fullscreen' + } +} satisfies Meta + +export default meta +type Story = StoryObj + +export const Default: Story = {}