From 28becadc1f268c1e70a21784397731bbce912542 Mon Sep 17 00:00:00 2001 From: William Correa Date: Sun, 8 Mar 2026 18:20:01 -0300 Subject: [PATCH 1/2] feat: add some new components --- packages/core/src/fields/base.ts | 14 +- packages/core/src/fields/list.ts | 28 +- packages/core/src/index.ts | 3 +- packages/core/src/locales/pt-BR.ts | 7 +- packages/core/src/mock.ts | 2 + packages/core/src/schema.ts | 10 +- packages/core/src/types.ts | 47 +- packages/demo/src/settings/hooks.ts | 39 +- .../react-web/src/components/DataPage.tsx | 147 ++++- packages/react-web/src/components/Dialog.tsx | 57 +- packages/react-web/src/components/Form.tsx | 108 ++-- .../src/components/PageActionsContext.tsx | 25 + packages/react-web/src/components/Table.tsx | 548 +++++++++++++----- .../components/defaults/ColumnSelector.tsx | 5 +- .../src/components/defaults/DebugPanel.tsx | 23 +- .../src/components/defaults/EmptyState.tsx | 56 +- .../src/components/defaults/Pagination.tsx | 75 ++- packages/react-web/src/components/registry.ts | 52 ++ packages/react-web/src/contracts/component.ts | 3 + packages/react-web/src/index.ts | 9 +- .../react-web/src/renderers/DateField.tsx | 5 +- .../react-web/src/renderers/ListField.tsx | 243 ++++---- .../src/renderers/MultiSelectField.tsx | 9 +- .../react-web/src/renderers/NumberField.tsx | 6 +- .../react-web/src/renderers/SelectField.tsx | 56 +- .../react-web/src/renderers/TextField.tsx | 49 +- .../react-web/src/renderers/TextareaField.tsx | 49 +- .../react-web/src/renderers/TimeField.tsx | 5 +- .../react-web/src/renderers/ToggleField.tsx | 5 +- .../src/renderers/list/useListComponent.ts | 91 +++ .../src/renderers/list/useListDialog.ts | 33 ++ packages/react-web/src/support/Icon.tsx | 1 + packages/react-web/src/support/i18n.ts | 82 +++ packages/react-web/src/theme/default.ts | 42 +- packages/react-web/src/types.ts | 67 ++- packages/react/src/types.ts | 7 +- packages/react/src/use-data-form.ts | 51 +- packages/react/src/use-data-table.ts | 50 +- packages/svelte/src/types.ts | 2 +- packages/svelte/src/use-data-form.ts | 37 +- packages/svelte/src/use-data-table.ts | 42 +- packages/vue/src/types.ts | 2 +- packages/vue/src/use-data-form.ts | 37 +- packages/vue/src/use-data-table.ts | 42 +- .../tests/src/domain/person/hooks.test.ts | 54 +- .../tests/src/domain/person/hooks.test.ts | 54 +- .../tests/src/domain/person/hooks.test.ts | 54 +- .../tests/src/domain/person/hooks.test.ts | 54 +- 48 files changed, 1798 insertions(+), 689 deletions(-) create mode 100644 packages/react-web/src/components/PageActionsContext.tsx create mode 100644 packages/react-web/src/components/registry.ts create mode 100644 packages/react-web/src/renderers/list/useListComponent.ts create mode 100644 packages/react-web/src/renderers/list/useListDialog.ts create mode 100644 packages/react-web/src/support/i18n.ts diff --git a/packages/core/src/fields/base.ts b/packages/core/src/fields/base.ts index 03708d9..ef84498 100644 --- a/packages/core/src/fields/base.ts +++ b/packages/core/src/fields/base.ts @@ -94,6 +94,18 @@ export class FieldDefinition { } toConfig(): FieldConfig { - return structuredClone(this._config) + return cloneWithFunctions(this._config) as FieldConfig } } + +function cloneWithFunctions(value: unknown): unknown { + if (value === null || typeof value !== 'object') return value + if (typeof value === 'function') return value + if (Array.isArray(value)) return value.map(cloneWithFunctions) + const result: Record = {} + for (const key of Object.keys(value as object)) { + const v = (value as Record)[key] + result[key] = typeof v === 'function' ? v : cloneWithFunctions(v) + } + return result +} diff --git a/packages/core/src/fields/list.ts b/packages/core/src/fields/list.ts index cd36293..ae86e46 100644 --- a/packages/core/src/fields/list.ts +++ b/packages/core/src/fields/list.ts @@ -1,4 +1,5 @@ import { FieldDefinition } from './base' +import type { SchemaProvide } from '../types' import type { SchemaDefinition } from '../schema' export class ListFieldDefinition extends FieldDefinition[]> { @@ -6,7 +7,7 @@ export class ListFieldDefinition extends FieldDefinition super('list', 'array', attrs) } - itemSchema(schema: SchemaDefinition): this { + itemSchema(schema: SchemaProvide): this { this._config.attrs = { ...this._config.attrs, itemSchema: schema } return this } @@ -25,8 +26,29 @@ export class ListFieldDefinition extends FieldDefinition this._config.validations = [...this._config.validations, { rule: 'maxItems', params: { value: n } }] return this } + + events(events: Record): this { + this._config.attrs = { ...this._config.attrs, events } + return this + } + + hooks(hooks: Record): this { + this._config.attrs = { ...this._config.attrs, hooks } + return this + } + + handlers(handlers: Record): this { + this._config.attrs = { ...this._config.attrs, handlers } + return this + } } -export function list(attrs?: Record): ListFieldDefinition { - return new ListFieldDefinition(attrs) +export function list(schema?: SchemaDefinition | Record): ListFieldDefinition { + if (schema && 'provide' in schema && typeof (schema as SchemaDefinition).provide === 'function') { + const field = new ListFieldDefinition() + // call provide() so the stored value is plain data that survives deep cloning in toConfig() + field.itemSchema((schema as SchemaDefinition).provide()) + return field + } + return new ListFieldDefinition(schema as Record | undefined) } diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 9f5d660..4076994 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -32,7 +32,7 @@ export { isScopePermitted, isActionPermitted } from './permission' export { buildInitialState, isInScope } from './scope' -export { Position, Scope } from './types' +export { Position, Scope, FetchType } from './types' export { ptBR } from './locales/pt-BR' @@ -62,6 +62,7 @@ export type { HandlerContext, BootstrapHookContext, BootstrapHookFn, + FetchTypeValue, FetchHookContext, FetchHookFn, SchemaHooks, diff --git a/packages/core/src/locales/pt-BR.ts b/packages/core/src/locales/pt-BR.ts index 096da0b..9ee06d8 100644 --- a/packages/core/src/locales/pt-BR.ts +++ b/packages/core/src/locales/pt-BR.ts @@ -10,10 +10,13 @@ export const ptBR = { destroy: "Excluir", "create.invalid": "Corrija os erros antes de enviar", "create.success": "Registro criado com sucesso", + "create.error": "Erro ao criar registro", "update.invalid": "Corrija os erros antes de enviar", "update.success": "Registro atualizado com sucesso", - "destroy.confirm": "Deseja realmente excluir este registro?", + "update.error": "Erro ao atualizar registro", + "destroy.confirm": "Tem certeza que deseja excluir este registro? Esta ação não pode ser desfeita.", "destroy.success": "Registro excluído com sucesso", + "destroy.error": "Erro ao excluir registro", }, table: { columns: "Colunas", @@ -25,7 +28,7 @@ export const ptBR = { actions: "Ações", recordsPerPage: "Registros por página", }, - dialog: { confirm: "Confirmar", cancel: "Cancelar", ok: "OK", alert: "Alerta" }, + dialog: { confirm: "Confirmar", confirmDestructiveTitle: "Confirmar exclusão", cancel: "Cancelar", ok: "OK", alert: "Alerta" }, scopes: { index: "Listagem", add: "Cadastro", view: "Visualização", edit: "Edição" }, forbidden: "Acesso não permitido", }, diff --git a/packages/core/src/mock.ts b/packages/core/src/mock.ts index 741ef42..ea466db 100644 --- a/packages/core/src/mock.ts +++ b/packages/core/src/mock.ts @@ -74,6 +74,7 @@ export function createMockContext> ( push: fn(), back: fn(), replace: fn(), + open: fn(), }, dialog: { confirm: fn(async () => true), @@ -97,6 +98,7 @@ export function createMockContext> ( valid: true, validate: fn(() => true), reset: fn(), + setErrors: fn(), } const table: TableContract = { diff --git a/packages/core/src/schema.ts b/packages/core/src/schema.ts index 1764436..fadc841 100644 --- a/packages/core/src/schema.ts +++ b/packages/core/src/schema.ts @@ -45,7 +45,6 @@ type SchemaHandlers> = type HookBootstrapContext> = { context: Record - hydrate(data: Record): void schema: { [K in keyof F]: FieldProxy } component: ComponentContract } @@ -53,12 +52,11 @@ type HookBootstrapContext> = { type HookBootstrapFn> = (ctx: HookBootstrapContext) => void | Promise -type HookFetchContext = { - params: PaginateParams - component: ComponentContract -} +type HookFetchContext = + | { type: 'record'; context: Record; params: PaginateParams; hydrate(data: Record): void; component: ComponentContract } + | { type: 'collection'; context: Record; params: PaginateParams; hydrate(result: PaginatedResult>): void; component: ComponentContract } -type HookFetchFn = (ctx: HookFetchContext) => Promise>> +type HookFetchFn = (ctx: HookFetchContext) => void | Promise type SchemaHooks> = { bootstrap?: Partial>> diff --git a/packages/core/src/types.ts b/packages/core/src/types.ts index c6a2482..f7da96b 100644 --- a/packages/core/src/types.ts +++ b/packages/core/src/types.ts @@ -3,6 +3,7 @@ export const Position = { footer: "footer", floating: "floating", row: "row", + header: "header", } as const; export type PositionValue = typeof Position[keyof typeof Position] @@ -14,7 +15,7 @@ export const Scope = { edit: "edit", } as const; -export type ScopeValue = typeof Scope[keyof typeof Scope] +export type ScopeValue = typeof Scope[keyof typeof Scope] | string; export interface FormConfig { width: number; @@ -96,10 +97,12 @@ export interface NavigatorContract { back (): void; replace (path: string, params?: Record): void; + + open (route: ScopeRoute, params?: Record): void; } export interface DialogContract { - confirm (message: string): Promise; + confirm (message: string, options?: { destructive?: boolean }): Promise; alert (message: string): Promise; } @@ -128,6 +131,8 @@ export interface FormContract { validate (): boolean; reset (values?: Record): void; + + setErrors (errors: Record): void; } export interface ComponentContract { @@ -184,23 +189,41 @@ export interface HandlerContext { component: ComponentContract form?: FormContract table?: TableContract + value?: unknown } -export interface BootstrapHookContext { +export interface BootstrapHookContext<_T = Record> { context: Record - hydrate(data: Record): void schema: Record component: ComponentContract } -export type BootstrapHookFn = (ctx: BootstrapHookContext) => void | Promise - -export interface FetchHookContext { - params: PaginateParams - component: ComponentContract -} - -export type FetchHookFn = (ctx: FetchHookContext) => Promise>> +export type BootstrapHookFn<_T = Record> = (ctx: BootstrapHookContext) => void | Promise + +export const FetchType = { + record: 'record', + collection: 'collection', +} as const + +export type FetchTypeValue = typeof FetchType[keyof typeof FetchType] + +export type FetchHookContext> = + | { + type: 'record' + context: Record + params: PaginateParams + hydrate(data: T): void + component: ComponentContract + } + | { + type: 'collection' + context: Record + params: PaginateParams + hydrate(result: PaginatedResult): void + component: ComponentContract + } + +export type FetchHookFn> = (ctx: FetchHookContext) => void | Promise export interface SchemaHooks { bootstrap?: Partial> diff --git a/packages/demo/src/settings/hooks.ts b/packages/demo/src/settings/hooks.ts index 63cf0d9..c96f928 100644 --- a/packages/demo/src/settings/hooks.ts +++ b/packages/demo/src/settings/hooks.ts @@ -1,27 +1,36 @@ -import type { ServiceContract, BootstrapHookContext, FetchHookContext } from "@ybyra/core"; -import { Scope } from "@ybyra/core"; +import { type BootstrapHookContext, type FetchHookContext, FetchType, Scope, type ServiceContract } from '@ybyra/core' export function createDefault (service: ServiceContract) { return { bootstrap: { - async [Scope.view] ({ context, schema, hydrate }: BootstrapHookContext) { - if (!context.id) return; - const data = await service.read(context.id as string); - hydrate(data); + async [Scope.view] ({ schema }: BootstrapHookContext) { for (const field of Object.values(schema)) { - field.disabled = true; + field.disabled = true } }, - async [Scope.edit] ({ context, hydrate }: BootstrapHookContext) { - if (!context.id) return; - const data = await service.read(context.id as string); - hydrate(data); - }, }, fetch: { - async [Scope.index] ({ params }: FetchHookContext) { - return service.paginate(params); + async [Scope.view] (context: FetchHookContext) { + if (context.type !== FetchType.record || !context.context.id) { + return + } + const data = await service.read(context.context.id as string) + context.hydrate(data) + }, + async [Scope.edit] (context: FetchHookContext) { + if (context.type !== FetchType.record || !context.context.id) { + return + } + const data = await service.read(context.context.id as string) + context.hydrate(data) + }, + async [Scope.index] (context: FetchHookContext) { + if (context.type !== FetchType.collection) { + return + } + const result = await service.paginate(context.params) + context.hydrate(result) }, }, - }; + } } \ No newline at end of file diff --git a/packages/react-web/src/components/DataPage.tsx b/packages/react-web/src/components/DataPage.tsx index 36bf5cd..159bb94 100644 --- a/packages/react-web/src/components/DataPage.tsx +++ b/packages/react-web/src/components/DataPage.tsx @@ -1,10 +1,12 @@ -import type { ReactNode } from "react"; -import { useTranslation } from "react-i18next"; -import type { ScopeValue } from "@ybyra/core"; -import { isScopePermitted } from "@ybyra/core"; -import { useTheme } from "../theme/context"; -import type { Theme } from "../theme/default"; -import { Icon } from "../support/Icon"; +import type { ReactNode } from 'react' +import { useMemo } from 'react' +import { useTranslation } from 'react-i18next' +import type { ScopeValue } from '@ybyra/core' +import { isScopePermitted } from '@ybyra/core' +import { useTheme } from '../theme/context' +import type { Theme } from '../theme/default' +import { Icon } from '../support/Icon' +import { PageActionsContext, usePageActionsState } from './PageActionsContext' interface PageProps { domain: string; @@ -14,28 +16,78 @@ interface PageProps { permissions?: string[]; forbidden?: ReactNode; children: ReactNode; + /** @deprecated use layout="flat" instead */ + bare?: boolean; + layout?: 'card' | 'flat'; + title?: string; + subtitle?: string; + headerEnd?: ReactNode; } -export function DataPage({ domain, scope, maxWidth = 960, loading, permissions, forbidden, children }: PageProps) { - const { t } = useTranslation(); - const theme = useTheme(); - const styles = createStyles(theme); - const permitted = !permissions || isScopePermitted(domain, scope, permissions); +export function DataPage (props: PageProps) { + const { + domain, + scope, + maxWidth = 960, + loading, + permissions, + forbidden, + bare, + layout = 'card', + title, + subtitle, + headerEnd, + children + } = props + const { t } = useTranslation() + const theme = useTheme() + const styles = createStyles(theme) + const permitted = !permissions || isScopePermitted(domain, scope, permissions) + const { node: registeredActions, register } = usePageActionsState() + const contextValue = useMemo(() => ({ register }), [register]) if (loading) { return (
Loading...
- ); + ) } if (!permitted) { - if (forbidden) return <>{forbidden}; + if (forbidden) return <>{forbidden} return (
- -
{t("common.forbidden")}
+ +
{t('common.forbidden')}
- ); + ) + } + + if (bare || layout === 'flat') { + const resolvedTitle = title ?? t(`${domain}.title`) + if (layout !== 'flat' && bare) { + return <>{children} + } + const resolvedHeaderEnd = headerEnd ?? registeredActions + return ( + +
+
+
+
+
{resolvedTitle}
+ {subtitle &&
{subtitle}
} +
+ {resolvedHeaderEnd &&
{resolvedHeaderEnd}
} +
+ {children} +
+
+
+ ) } return ( @@ -45,23 +97,23 @@ export function DataPage({ domain, scope, maxWidth = 960, loading, permissions, {children} - ); + ) } const createStyles = (theme: Theme) => ({ scroll: { - minHeight: "100vh", + minHeight: '100vh', backgroundColor: theme.colors.background, padding: theme.spacing.xl, paddingTop: 60, }, container: { - width: "100%", - margin: "0 auto", + width: '100%', + margin: '0 auto', backgroundColor: theme.colors.card, borderRadius: theme.borderRadius.lg, padding: theme.spacing.xxl, - boxShadow: "0 2px 8px rgba(0, 0, 0, 0.1)", + boxShadow: '0 2px 8px rgba(0, 0, 0, 0.1)', }, title: { fontSize: theme.fontSize.xxl, @@ -69,23 +121,54 @@ const createStyles = (theme: Theme) => ({ marginBottom: theme.spacing.xxl, color: theme.colors.foreground, }, + flatScroll: { + // No minHeight/backgroundColor/padding — host app controls layout + }, + flatContainer: { + width: '100%', + margin: '0 auto', + }, + flatHeader: { + display: 'flex', + alignItems: 'flex-start', + justifyContent: 'space-between', + marginBottom: theme.spacing.xxl, + gap: theme.spacing.md, + }, + flatTitle: { + fontSize: theme.fontSize.xxl, + fontWeight: theme.fontWeight.bold, + color: theme.colors.foreground, + lineHeight: '1.2', + }, + flatSubtitle: { + fontSize: theme.fontSize.sm, + color: theme.colors.mutedForeground, + marginTop: theme.spacing.xs, + }, + flatHeaderEnd: { + display: 'flex', + alignItems: 'center', + gap: theme.spacing.md, + flexShrink: 0, + }, loading: { - display: "flex", - justifyContent: "center", - alignItems: "center", - minHeight: "100vh", + display: 'flex', + justifyContent: 'center', + alignItems: 'center', + minHeight: '100vh', color: theme.colors.mutedForeground, }, forbidden: { - display: "flex", - flexDirection: "column" as const, - justifyContent: "center", - alignItems: "center", - minHeight: "100vh", + display: 'flex', + flexDirection: 'column' as const, + justifyContent: 'center', + alignItems: 'center', + minHeight: '100vh', gap: 8, }, forbiddenText: { fontSize: theme.fontSize.sm, color: theme.colors.mutedForeground, }, -}); +}) diff --git a/packages/react-web/src/components/Dialog.tsx b/packages/react-web/src/components/Dialog.tsx index 29ec835..d3c85ac 100644 --- a/packages/react-web/src/components/Dialog.tsx +++ b/packages/react-web/src/components/Dialog.tsx @@ -4,6 +4,8 @@ import { useTranslation } from "react-i18next"; import type { DialogContract } from "@ybyra/core"; import { useTheme } from "../theme/context"; import type { Theme } from "../theme/default"; +import { getComponent } from "./registry"; +import type { DialogButtonProps } from "../types"; type DialogType = "confirm" | "alert"; @@ -11,10 +13,28 @@ interface DialogState { visible: boolean; type: DialogType; message: string; + options?: { destructive?: boolean }; } const DialogContext = createContext(null); +function DefaultDialogButton({ label, variant, onClick }: DialogButtonProps) { + const theme = useTheme(); + const styles = createStyles(theme); + const style = variant === "cancel" + ? styles.cancelButton + : { + ...styles.okButton, + backgroundColor: variant === "destructive" ? theme.colors.destructive : theme.colors.primary, + }; + + return ( + + ); +} + export function DialogProvider({ children }: { children: React.ReactNode }) { const { t } = useTranslation(); const theme = useTheme(); @@ -31,25 +51,30 @@ export function DialogProvider({ children }: { children: React.ReactNode }) { resolveRef.current = null; }, []); - const show = useCallback((type: DialogType, message: string): Promise => { + const show = useCallback((type: DialogType, message: string, options?: { destructive?: boolean }): Promise => { return new Promise((resolve) => { resolveRef.current = resolve; - setDialog({ visible: true, type, message }); + setDialog({ visible: true, type, message, options }); }); }, []); const contract: DialogContract = { - confirm: (message: string) => show("confirm", message), + confirm: (message: string, options?: { destructive?: boolean }) => show("confirm", message, options), async alert(message: string) { await show("alert", message); }, }; - const title = dialog.type === "confirm" - ? t("common.dialog.confirm") - : t("common.dialog.alert"); + const isDestructive = dialog.type === "confirm" && dialog.options?.destructive; + + const title = isDestructive + ? t("common.dialog.confirmDestructiveTitle") + : dialog.type === "confirm" + ? t("common.dialog.confirm") + : t("common.dialog.alert"); const styles = createStyles(theme); + const DialogBtn = getComponent("DialogButton") ?? DefaultDialogButton; return ( @@ -61,21 +86,17 @@ export function DialogProvider({ children }: { children: React.ReactNode }) {
{t(dialog.message)}
{dialog.type === "confirm" && ( - + /> )} - + />
, diff --git a/packages/react-web/src/components/Form.tsx b/packages/react-web/src/components/Form.tsx index d6bd866..94f77d0 100644 --- a/packages/react-web/src/components/Form.tsx +++ b/packages/react-web/src/components/Form.tsx @@ -1,18 +1,19 @@ -import { useCallback } from "react"; -import React from "react"; -import { useTranslation } from "react-i18next"; -import { useDataForm } from "@ybyra/react"; -import type { UseDataFormOptions } from "@ybyra/react"; -import { fill, createFiller } from "@ybyra/core"; -import type { FillerRegistry } from "@ybyra/core"; -import { useTheme } from "../theme/context"; -import type { Theme } from "../theme/default"; -import { ActionBar } from "./ActionBar"; -import { FieldsGrid as DefaultFieldsGrid } from "./defaults/FieldsGrid"; -import { DebugPanel } from "./defaults/DebugPanel"; -import { ds } from "../support/ds"; -import type { DataFormComponents, SlotRendererProps } from "../types"; -import "../renderers"; +import React, { useCallback, useLayoutEffect } from 'react' +import { useTranslation } from 'react-i18next' +import type { UseDataFormOptions } from '@ybyra/react' +import { useDataForm } from '@ybyra/react' +import type { FillerRegistry } from '@ybyra/core' +import { createFiller, fill, Position } from '@ybyra/core' +import { useTheme } from '../theme/context' +import type { Theme } from '../theme/default' +import { ActionBar as DefaultActionBar, ActionButton as DefaultActionButton } from './ActionBar' +import { FieldsGrid as DefaultFieldsGrid } from './defaults/FieldsGrid' +import { DebugPanel } from './defaults/DebugPanel' +import { ds } from '../support/ds' +import type { DataFormComponents, SlotRendererProps } from '../types' +import { usePageActions } from './PageActionsContext' +import { getComponent } from './registry' +import '../renderers' interface DataFormProps extends UseDataFormOptions { debug?: boolean; @@ -21,29 +22,47 @@ interface DataFormProps extends UseDataFormOptions { slots?: Record>; } -export function DataForm({ debug, components, filler, slots, ...props }: DataFormProps) { - const { t } = useTranslation(); - const theme = useTheme(); - const form = useDataForm({ ...props, translate: props.translate ?? t }); - const styles = createStyles(theme); - const ResolvedActionBar = components?.ActionBar ?? ActionBar; - const ResolvedFieldsGrid = components?.FieldsGrid ?? DefaultFieldsGrid; - const ResolvedLoading = components?.Loading; +export function DataForm ({ debug, components, filler, slots, ...props }: DataFormProps) { + const { t } = useTranslation() + const theme = useTheme() + const form = useDataForm({ ...props, translate: props.translate ?? t }) + const styles = createStyles(theme) + const ResolvedActionBar = components?.ActionBar ?? getComponent('ActionBar') ?? DefaultActionBar + const ResolvedActionButton = components?.ActionButton ?? getComponent('ActionButton') ?? DefaultActionButton + const ResolvedFieldsGrid = components?.FieldsGrid ?? getComponent('FieldsGrid') ?? DefaultFieldsGrid + const ResolvedLoading = components?.Loading ?? getComponent('Loading') + const pageActions = usePageActions() + + useLayoutEffect(() => { + if (!pageActions) return + const headerActions = form.actions.filter((a) => a.config.positions.includes(Position.header)) + if (headerActions.length === 0) return + pageActions.register( +
+ {headerActions.map((a) => )} +
+ ) + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [pageActions, props.schema.domain]) const handleFill = useCallback(() => { - const fillerFn = filler ? createFiller(filler) : fill; - form.setValues(fillerFn(props.schema.fields, props.schema.identity)); - }, [filler, form, props.schema]); + const fillerFn = filler ? createFiller(filler) : fill + form.setValues(fillerFn(props.schema.fields, props.schema.identity)) + }, [filler, form, props.schema]) if (form.loading) { - if (ResolvedLoading) return ; + if (ResolvedLoading) return return (
Loading...
- ); + ) } return ( -
+
{form.sections.map((section, index) => { - if (section.kind === "group") { + if (section.kind === 'group') { if (components?.GroupWrapper) { - const GroupWrapper = components.GroupWrapper; + const GroupWrapper = components.GroupWrapper return ( - ); + ) } return (
- ); + ) } return (
- ); + ) })} {components?.Divider @@ -119,26 +138,27 @@ export function DataForm({ debug, components, filler, slots, ...props }: DataFor {debug && ( form.reset() }, - { icon: "check", color: theme.colors.success, onPress: () => form.validate() }, - { icon: "refresh-cw", color: theme.colors.info, onPress: () => window.location.reload() }, + { icon: 'zap', color: theme.colors.warning, onPress: handleFill }, + { icon: 'rotate-ccw', color: theme.colors.mutedForeground, onPress: () => form.reset() }, + { icon: 'check', color: theme.colors.success, onPress: () => form.validate() }, + { icon: 'refresh-cw', color: theme.colors.info, onPress: () => window.location.reload() }, ]} entries={[ - { title: "State", content: JSON.stringify(form.state, null, 2) }, - { title: "Errors", content: JSON.stringify(form.errors, null, 2) }, + { title: 'State', content: JSON.stringify(form.state, null, 2) }, + { title: 'Errors', content: JSON.stringify(form.errors, null, 2) }, + { title: 'Schema', content: JSON.stringify(props.schema, null, 2), collapsed: true }, ]} meta={`dirty: ${String(form.dirty)} | valid: ${String(form.valid)}`} /> )}
- ); + ) } const createStyles = (theme: Theme) => ({ loadingContainer: { padding: `${theme.spacing.xxl}px 0`, - textAlign: "center" as const, + textAlign: 'center' as const, color: theme.colors.mutedForeground, }, group: { @@ -156,4 +176,4 @@ const createStyles = (theme: Theme) => ({ backgroundColor: theme.colors.border, margin: `${theme.spacing.xl}px 0`, }, -}); +}) diff --git a/packages/react-web/src/components/PageActionsContext.tsx b/packages/react-web/src/components/PageActionsContext.tsx new file mode 100644 index 0000000..912bdd8 --- /dev/null +++ b/packages/react-web/src/components/PageActionsContext.tsx @@ -0,0 +1,25 @@ +import { createContext, useContext, useState, useRef, useCallback } from "react"; +import type { ReactNode } from "react"; + +interface PageActionsContextValue { + register: (node: ReactNode) => void; +} + +export const PageActionsContext = createContext(null); + +export function usePageActions() { + return useContext(PageActionsContext); +} + +export function usePageActionsState() { + const [node, setNode] = useState(null); + const registeredRef = useRef(false); + + const register = useCallback((newNode: ReactNode) => { + if (registeredRef.current) return; + registeredRef.current = true; + setNode(newNode); + }, []); + + return { node, register }; +} diff --git a/packages/react-web/src/components/Table.tsx b/packages/react-web/src/components/Table.tsx index 6a8afa9..d0e3279 100644 --- a/packages/react-web/src/components/Table.tsx +++ b/packages/react-web/src/components/Table.tsx @@ -1,30 +1,50 @@ -import { useTranslation } from "react-i18next"; -import { useDataTable, resolveActionLabel, resolveActionIcon } from "@ybyra/react"; -import type { UseDataTableOptions, ResolvedAction, ResolvedColumn } from "@ybyra/react"; -import { useTheme } from "../theme/context"; -import type { Theme } from "../theme/default"; -import { ActionBar, ActionButton } from "./ActionBar"; -import { Pagination as DefaultPagination } from "./defaults/Pagination"; -import { ColumnSelector as DefaultColumnSelector } from "./defaults/ColumnSelector"; -import { EmptyState as DefaultEmptyState } from "./defaults/EmptyState"; -import { DebugPanel } from "./defaults/DebugPanel"; -import { Icon } from "../support/Icon"; -import { ds } from "../support/ds"; -import type { DataTableComponents, RowActionProps } from "../types"; -import "../renderers"; +import type React from 'react' +import { useLayoutEffect, useMemo, useState } from 'react' +import { useTranslation } from 'react-i18next' +import type { ResolvedColumn, UseDataTableOptions } from '@ybyra/react' +import { resolveActionIcon, resolveActionLabel, useDataTable } from '@ybyra/react' +import type { ActionConfig } from '@ybyra/core' +import { Position } from '@ybyra/core' +import { useTheme } from '../theme/context' +import type { Theme } from '../theme/default' +import { ActionBar as DefaultActionBar, ActionButton as DefaultActionButton } from './ActionBar' +import { Pagination as DefaultPagination } from './defaults/Pagination' +import { ColumnSelector as DefaultColumnSelector } from './defaults/ColumnSelector' +import { EmptyState as DefaultEmptyState } from './defaults/EmptyState' +import { DebugPanel } from './defaults/DebugPanel' +import { Icon } from '../support/Icon' +import { ds } from '../support/ds' +import type { + DataTableComponents, + DataTableEmptyStateInput, + EmptyStateProps, + RowActionProps, + SearchBarProps +} from '../types' +import { usePageActions } from './PageActionsContext' +import { getComponent } from './registry' +import { resolveFieldLabel } from '../support/i18n' +import '../renderers' interface DataTableProps extends UseDataTableOptions { debug?: boolean; components?: DataTableComponents; + selectable?: boolean; + actionsPosition?: 'start' | 'end'; + showColumnSelector?: boolean; + showTopActions?: boolean; + searchSlot?: React.ReactNode; + emptyState?: DataTableEmptyStateInput; + value?: Record[] } -function DefaultRowAction({ action, domain }: RowActionProps) { - const { t } = useTranslation(); - const theme = useTheme(); - const color = action.config.variant === "destructive" +function DefaultRowAction ({ action, domain }: RowActionProps) { + const { t } = useTranslation() + const theme = useTheme() + const color = action.config.variant === 'destructive' ? theme.colors.destructive - : theme.colors.mutedForeground; - const icon = resolveActionIcon(domain, action.name) as string | undefined; + : theme.colors.mutedForeground + const icon = resolveActionIcon(domain, action.name) as string | undefined return ( - ); + ) } -function SortIcon({ field, sortField, sortOrder }: { field: string; sortField?: string; sortOrder?: string }) { - const theme = useTheme(); - if (sortField !== field) return null; +function SortIcon ({ field, sortField, sortOrder }: { field: string; sortField?: string; sortOrder?: string }) { + const theme = useTheme() + if (sortField !== field) return null return ( - ); + ) } -function columnWidth(col: ResolvedColumn, display?: string | ((r: Record) => string)): React.CSSProperties { - if (typeof col.table.width === "number") return { width: col.table.width }; - if (col.table.width === "auto" && col.name === display) return { minWidth: 150 }; - return { width: 150 }; +function columnWidth (col: ResolvedColumn, display?: string | ((r: Record) => string)): React.CSSProperties { + if (typeof col.table.width === 'number') return { width: col.table.width } + if (col.table.width === 'auto' && col.name === display) return { minWidth: 150 } + return { width: 150 } } -function TableContent({ table, domain, display, components }: { +function TableContent ({ table, domain, display, components, selectable, actionsPosition, emptyState }: { table: ReturnType; domain: string; display?: string | ((r: Record) => string); components?: DataTableComponents; + selectable: boolean; + actionsPosition: 'start' | 'end'; + emptyState?: EmptyStateProps; }) { - const { t } = useTranslation(); - const theme = useTheme(); - const styles = createStyles(theme); - const { columns, rows, loading, empty, sortField, sortOrder, setSort, isSelected, toggleSelect, selectAll, clearSelection, selected, formatValue, getIdentity, getRowActions } = table; + const { t, i18n } = useTranslation() + const theme = useTheme() + const styles = createStyles(theme) + const { + columns, + rows, + loading, + empty, + sortField, + sortOrder, + setSort, + isSelected, + toggleSelect, + selectAll, + clearSelection, + selected, + formatValue, + getIdentity, + getRowActions + } = table + + const allSelected = rows.length > 0 && selected.length === rows.length + const RowAction = components?.RowAction ?? getComponent('RowAction') ?? DefaultRowAction + const LoadingComponent = components?.Loading ?? getComponent('Loading') + const EmptyComponent = components?.EmptyState ?? getComponent('EmptyState') ?? DefaultEmptyState - const allSelected = rows.length > 0 && selected.length === rows.length; - const RowAction = components?.RowAction ?? DefaultRowAction; - const LoadingComponent = components?.Loading; - const EmptyComponent = components?.EmptyState ?? DefaultEmptyState; + const actionsHeaderCell = ( + + {t('common.table.actions')} + + ) + + const actionsDataCell = (row: Record) => { + const rowActions = getRowActions(row) + return ( + + {rowActions.map((a) => )} + + ) + } return (
- +
- - + {selectable && ( + + )} + {actionsPosition === 'start' && actionsHeaderCell} {columns.map((col) => { if (components?.HeaderCell) { - const HeaderCell = components.HeaderCell; + const HeaderCell = components.HeaderCell return ( - - ); + ) } return ( - ); + ) })} + {actionsPosition === 'end' && actionsHeaderCell} {loading && ( - )} {empty && ( - )} {!loading && rows.map((row) => { - const id = getIdentity(row); - const rowActions = getRowActions(row); + const id = getIdentity(row) return ( - - + {selectable && ( + + )} + {actionsPosition === 'start' && actionsDataCell(row)} {columns.map((col) => { if (components?.DataCell) { - const DataCell = components.DataCell; + const DataCell = components.DataCell return ( - - ); + ) } return ( - - ); + ) })} + {actionsPosition === 'end' && actionsDataCell(row)} - ); + ) })}
- - - {t("common.table.actions")} - + + + col.table.sortable && setSort(col.name)} {...ds(`header:${col.name}`)} > -
- {t(`${domain}.fields.${col.name}`, { defaultValue: col.name })} - {col.table.sortable && } +
+ {resolveFieldLabel(i18n, t, domain, col.name)} + {col.table.sortable && }
- {LoadingComponent ? : "Loading..."} + + {LoadingComponent ? : 'Loading...'}
- + +
- - - {rowActions.map((a) => )} - + + + + {formatValue(col.name, row[col.name], row)}
- ); + ) } -export function DataTable({ debug, components, ...props }: DataTableProps) { - const { t } = useTranslation(); - const theme = useTheme(); - const domain = props.schema.domain; - const table = useDataTable({ ...props, translate: props.translate ?? t }); - const styles = createStyles(theme); +export function DataTable (dataTableProps: DataTableProps) { + const { + debug, + components, + selectable = true, + actionsPosition = 'start', + showColumnSelector = true, + showTopActions = true, + searchSlot, + emptyState, + ...props + } = dataTableProps + const { t } = useTranslation() + const theme = useTheme() + const domain = props.schema.domain + const table = useDataTable({ ...props, translate: props.translate ?? t }) + const styles = createStyles(theme) + const pageActions = usePageActions() + const ResolvedActionBar = components?.ActionBar ?? getComponent('ActionBar') ?? DefaultActionBar + const ResolvedActionButton = components?.ActionButton ?? getComponent('ActionButton') ?? DefaultActionButton + const ResolvedPagination = components?.Pagination ?? getComponent('Pagination') ?? DefaultPagination + const ResolvedColumnSelector = components?.ColumnSelector ?? getComponent('ColumnSelector') ?? DefaultColumnSelector + const ResolvedSearchBar = components?.SearchBar ?? getComponent('SearchBar') as React.ComponentType | undefined + const EmptyComponent = components?.EmptyState ?? getComponent('EmptyState') ?? DefaultEmptyState + const [search, setSearch] = useState('') - const ResolvedActionBar = components?.ActionBar ?? ActionBar; - const ResolvedActionButton = components?.ActionButton ?? ActionButton; - const ResolvedPagination = components?.Pagination ?? DefaultPagination; - const ResolvedColumnSelector = components?.ColumnSelector ?? DefaultColumnSelector; + const resolvedEmptyState = useMemo((): EmptyStateProps | undefined => { + if (!emptyState) return undefined + const { action, ...rest } = emptyState + if (!action) return { ...rest } - return ( -
-
-
- {table.actions - .filter((a) => a.config.positions.includes("top")) - .map((a) => )} -
- a.name === action) + if (!resolved) return { ...rest } + return { + ...rest, + action: { + label: resolveActionLabel(t, domain, resolved.name), + icon: resolveActionIcon(domain, resolved.name) as string | undefined, + onPress: resolved.execute, + }, + } + } + + // ActionConfig → resolve por match de propriedades com table.actions + const config = action as ActionConfig + const resolved = table.actions.find(a => + a.config.variant === config.variant && + a.config.positions.join() === config.positions.join() && + a.config.order === config.order + ) + if (!resolved) return { ...rest } + return { + ...rest, + action: { + label: resolveActionLabel(t, domain, resolved.name), + icon: resolveActionIcon(domain, resolved.name) as string | undefined, + onPress: resolved.execute, + }, + } + }, [emptyState, table.actions, domain, t]) + + useLayoutEffect(() => { + if (!pageActions) return + const headerActions = table.actions.filter((a) => + a.config.positions.includes(Position.header) && + !a.config.positions.includes(Position.row) + ) + if (headerActions.length === 0) return + pageActions.register( +
+ {headerActions.map((a) => )} +
+ ) + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [pageActions, domain]) + + if (table.empty && !table.loading) { + return ( +
+ + + + -
- {table.selected.length > 0 && ( - {t("common.table.selected", { count: table.selected.length })} + {debug && ( + table.reload() }]} + entries={[{ title: 'State', content: 'empty' }]} + /> + )} +
+ ) + } + + const hasToolbar = showTopActions || showColumnSelector || table.selected.length > 0 + + return ( +
+ {hasToolbar && ( +
+
+ {showTopActions && table.actions + .filter((a) => a.config.positions.includes('top')) + .map((a) => )} +
+ {showColumnSelector && ( + )} +
+ {table.selected.length > 0 && ( + {t('common.table.selected', { count: table.selected.length })} + )} +
-
+ )} + + {searchSlot + ?
{searchSlot}
+ : ResolvedSearchBar + ?
+ : null + }
@@ -246,46 +458,61 @@ export function DataTable({ debug, components, ...props }: DataTableProps) { setLimit={table.setLimit} /> - - + + {debug && ( table.reload() }, - { icon: "x-square", color: theme.colors.mutedForeground, onPress: () => table.clearSelection() }, - { icon: "filter", color: theme.colors.mutedForeground, onPress: () => table.clearFilters() }, + { icon: 'refresh-cw', color: theme.colors.info, onPress: () => table.reload() }, + { icon: 'x-square', color: theme.colors.mutedForeground, onPress: () => table.clearSelection() }, + { icon: 'filter', color: theme.colors.mutedForeground, onPress: () => table.clearFilters() }, ]} entries={[ - { title: `Rows (${table.rows.length})`, content: JSON.stringify(table.rows.map((r) => table.getIdentity(r)), null, 2) }, - { title: `Selected (${table.selected.length})`, content: JSON.stringify(table.selected.map((r) => table.getIdentity(r)), null, 2) }, - { title: "Filters", content: JSON.stringify(table.filters, null, 2) }, + { + title: `Rows (${table.rows.length})`, + content: JSON.stringify(table.rows.map((r) => table.getIdentity(r)), null, 2) + }, + { + title: `Selected (${table.selected.length})`, + content: JSON.stringify(table.selected.map((r) => table.getIdentity(r)), null, 2) + }, + { title: 'Filters', content: JSON.stringify(table.filters, null, 2) }, + { title: 'Schema', content: JSON.stringify(props.schema, null, 2), collapsed: true }, ]} - meta={`page: ${table.page}/${table.totalPages} | total: ${table.total} | sort: ${table.sortField ?? "none"} ${table.sortOrder ?? ""}`} + meta={`page: ${table.page}/${table.totalPages} | total: ${table.total} | sort: ${table.sortField ?? 'none'} ${table.sortOrder ?? ''}`} /> )}
- ); + ) } const createStyles = (theme: Theme) => ({ toolbar: { - display: "flex", - alignItems: "center", - justifyContent: "space-between", + display: 'flex', + alignItems: 'center', + justifyContent: 'space-between', marginBottom: theme.spacing.md, zIndex: 10, gap: theme.spacing.md, }, toolbarStart: { - display: "flex", - flexWrap: "wrap" as const, + display: 'flex', + flexWrap: 'wrap' as const, gap: theme.spacing.md, flex: 1, }, toolbarEnd: { - display: "flex", - justifyContent: "flex-end", + display: 'flex', + justifyContent: 'flex-end', flex: 1, }, selectionInfo: { @@ -293,28 +520,31 @@ const createStyles = (theme: Theme) => ({ color: theme.colors.primary, fontWeight: theme.fontWeight.semibold, }, + searchSlot: { + marginBottom: theme.spacing.md, + }, tableContainer: { border: `1px solid ${theme.colors.border}`, borderRadius: theme.borderRadius.md, - overflow: "hidden", + overflow: 'hidden', minHeight: 500, }, tableScroll: { - overflowX: "auto" as const, + overflowX: 'auto' as const, }, table: { - width: "100%", - borderCollapse: "collapse" as const, - minWidth: "100%", + width: '100%', + borderCollapse: 'collapse' as const, + minWidth: '100%', }, headerRow: { backgroundColor: theme.colors.muted, - borderBottom: `2px solid ${theme.colors.border}`, + borderBottom: `1px solid ${theme.colors.border}`, }, headerCell: { padding: `${theme.spacing.md}px ${theme.spacing.md}px`, - textAlign: "left" as const, - userSelect: "none" as const, + textAlign: 'left' as const, + userSelect: 'none' as const, }, headerText: { fontSize: theme.fontSize.sm, @@ -324,21 +554,21 @@ const createStyles = (theme: Theme) => ({ actionsHeaderCell: { width: 120, padding: `${theme.spacing.md}px ${theme.spacing.md}px`, - textAlign: "center" as const, + textAlign: 'center' as const, }, checkboxCell: { width: 44, - textAlign: "center" as const, + textAlign: 'center' as const, padding: `${theme.spacing.md}px 0`, - verticalAlign: "middle" as const, + verticalAlign: 'middle' as const, }, checkboxButton: { - display: "inline-flex", - alignItems: "center", - justifyContent: "center", - background: "none", - border: "none", - cursor: "pointer", + display: 'inline-flex', + alignItems: 'center', + justifyContent: 'center', + background: 'none', + border: 'none', + cursor: 'pointer', padding: 0, }, dataRow: { @@ -351,23 +581,23 @@ const createStyles = (theme: Theme) => ({ }, dataCell: { padding: `${theme.spacing.md}px ${theme.spacing.md}px`, - verticalAlign: "middle" as const, + verticalAlign: 'middle' as const, }, cellText: { fontSize: theme.fontSize.sm, color: theme.colors.foreground, - whiteSpace: "nowrap" as const, - overflow: "hidden", - textOverflow: "ellipsis", + whiteSpace: 'nowrap' as const, + overflow: 'hidden', + textOverflow: 'ellipsis', }, rowActionsCell: { width: 120, - textAlign: "center" as const, - verticalAlign: "middle" as const, + textAlign: 'center' as const, + verticalAlign: 'middle' as const, }, loadingCell: { - textAlign: "center" as const, + textAlign: 'center' as const, padding: `${theme.spacing.xxl}px 0`, color: theme.colors.mutedForeground, }, -}); +}) diff --git a/packages/react-web/src/components/defaults/ColumnSelector.tsx b/packages/react-web/src/components/defaults/ColumnSelector.tsx index 7978db4..c2d2df6 100644 --- a/packages/react-web/src/components/defaults/ColumnSelector.tsx +++ b/packages/react-web/src/components/defaults/ColumnSelector.tsx @@ -4,9 +4,10 @@ import { useTheme } from "../../theme/context"; import type { Theme } from "../../theme/default"; import { Icon } from "../../support/Icon"; import type { ColumnSelectorProps } from "../../types"; +import { resolveFieldLabel } from "../../support/i18n"; export function ColumnSelector({ availableColumns, visibleColumns, toggleColumn, domain }: ColumnSelectorProps) { - const { t } = useTranslation(); + const { t, i18n } = useTranslation(); const theme = useTheme(); const styles = createStyles(theme); const [open, setOpen] = useState(false); @@ -42,7 +43,7 @@ export function ColumnSelector({ availableColumns, visibleColumns, toggleColumn, onClick={() => toggleColumn(col.name)} > - {t(`${domain}.fields.${col.name}`, { defaultValue: col.name })} + {resolveFieldLabel(i18n, t, domain, col.name)} ); })} diff --git a/packages/react-web/src/components/defaults/DebugPanel.tsx b/packages/react-web/src/components/defaults/DebugPanel.tsx index 6f799a9..46e4ff5 100644 --- a/packages/react-web/src/components/defaults/DebugPanel.tsx +++ b/packages/react-web/src/components/defaults/DebugPanel.tsx @@ -13,6 +13,7 @@ interface DebugAction { interface DebugEntry { title: string; content: string; + collapsed?: boolean; } interface DebugPanelProps { @@ -21,6 +22,22 @@ interface DebugPanelProps { meta?: string; } +function CollapsibleEntry({ entry, styles, theme }: { entry: DebugEntry; styles: ReturnType; theme: Theme }) { + const [open, setOpen] = useState(!entry.collapsed); + return ( +
+
setOpen((v) => !v)} + > + + {entry.title} +
+ {open &&
{entry.content}
} +
+ ); +} + export function DebugPanel({ actions, entries, meta }: DebugPanelProps) { const theme = useTheme(); const styles = createStyles(theme); @@ -44,10 +61,7 @@ export function DebugPanel({ actions, entries, meta }: DebugPanelProps) { {expanded && ( <> {entries.map((entry, i) => ( -
-
{entry.title}
-
{entry.content}
-
+ ))} {meta &&
{meta}
} @@ -69,7 +83,6 @@ const createStyles = (theme: Theme) => ({ justifyContent: "space-between", flexWrap: "wrap" as const, gap: theme.spacing.sm, - marginBottom: theme.spacing.md, }, debugActions: { display: "flex", diff --git a/packages/react-web/src/components/defaults/EmptyState.tsx b/packages/react-web/src/components/defaults/EmptyState.tsx index 4112ac1..172e8af 100644 --- a/packages/react-web/src/components/defaults/EmptyState.tsx +++ b/packages/react-web/src/components/defaults/EmptyState.tsx @@ -1,17 +1,44 @@ +import type { ComponentType } from "react"; import { useTranslation } from "react-i18next"; import { useTheme } from "../../theme/context"; import type { Theme } from "../../theme/default"; import { Icon } from "../../support/Icon"; +import type { EmptyStateProps } from "../../types"; -export function EmptyState() { +export function EmptyState({ icon, title, subtitle, action }: EmptyStateProps) { const { t } = useTranslation(); const theme = useTheme(); const styles = createStyles(theme); + const resolvedTitle = title ?? t("common.table.empty"); + + const indicatorIconName = icon ?? "inbox"; + const IndicatorIcon = + typeof indicatorIconName !== "string" + ? (indicatorIconName as ComponentType<{ className?: string }>) + : null; + return (
- - {t("common.table.empty")} + {IndicatorIcon ? ( + + ) : ( + + )} + {resolvedTitle} + {subtitle && {subtitle}} + {action && ( + + )}
); } @@ -21,11 +48,30 @@ const createStyles = (theme: Theme) => ({ display: "flex", flexDirection: "column" as const, alignItems: "center", - padding: `${theme.spacing.xxl}px 0`, + padding: `${theme.spacing.xxl * 2}px 0`, gap: theme.spacing.sm, }, - text: { + title: { + fontSize: theme.fontSize.md, + color: theme.colors.foreground, + marginTop: theme.spacing.sm, + }, + subtitle: { fontSize: theme.fontSize.sm, color: theme.colors.mutedForeground, }, + actionButton: { + display: "inline-flex", + alignItems: "center", + marginTop: theme.spacing.md, + padding: `${theme.spacing.md}px ${theme.spacing.xl}px`, + borderRadius: theme.borderRadius.md, + border: "none", + cursor: "pointer", + fontFamily: "inherit", + } as const, + actionButtonText: { + fontSize: theme.fontSize.sm, + fontWeight: theme.fontWeight.semibold, + }, }); diff --git a/packages/react-web/src/components/defaults/Pagination.tsx b/packages/react-web/src/components/defaults/Pagination.tsx index 8a0dc23..15ed29b 100644 --- a/packages/react-web/src/components/defaults/Pagination.tsx +++ b/packages/react-web/src/components/defaults/Pagination.tsx @@ -2,18 +2,22 @@ import { useState, useEffect, useCallback } from "react"; import { useTranslation } from "react-i18next"; import { useTheme } from "../../theme/context"; import type { Theme } from "../../theme/default"; -import { Icon } from "../../support/Icon"; import type { PaginationProps } from "../../types"; const PAGE_SIZE_OPTIONS = [3, 5, 10, 25, 50]; +function getVisiblePages(current: number, total: number): number[] { + if (total <= 5) return Array.from({ length: total }, (_, i) => i + 1); + const start = Math.max(1, current - 2); + const end = Math.min(total, start + 4); + return Array.from({ length: end - start + 1 }, (_, i) => start + i); +} + export function Pagination({ page, limit, total, totalPages, setPage, setLimit }: PaginationProps) { const { t } = useTranslation(); const theme = useTheme(); const styles = createStyles(theme); const [open, setOpen] = useState(false); - const start = total === 0 ? 0 : (page - 1) * limit + 1; - const end = Math.min(page * limit, total); const handleClickOutside = useCallback(() => setOpen(false), []); @@ -24,10 +28,13 @@ export function Pagination({ page, limit, total, totalPages, setPage, setLimit } } }, [open, handleClickOutside]); + const visiblePages = getVisiblePages(page, totalPages); + return (
+ {/* Esquerda: Itens por página */}
- {t("common.table.recordsPerPage")} + {t("common.table.recordsPerPage")}:
{open && (
e.stopPropagation()}> @@ -56,23 +62,35 @@ export function Pagination({ page, limit, total, totalPages, setPage, setLimit }
+ {/* Direita: Anterior | páginas numeradas | Próximo */}
- {start}-{end} of {total} + + {visiblePages.map((p) => ( + + ))} +
@@ -83,7 +101,7 @@ const createStyles = (theme: Theme) => ({ pagination: { display: "flex", alignItems: "center", - justifyContent: "flex-end", + justifyContent: "space-between", gap: theme.spacing.lg, marginTop: theme.spacing.lg, marginBottom: theme.spacing.md, @@ -151,20 +169,39 @@ const createStyles = (theme: Theme) => ({ pageSizeOptionTextActive: { fontWeight: theme.fontWeight.semibold, }, - pageArrow: { - display: "flex", - alignItems: "center", - justifyContent: "center", - padding: 6, + navButton: { + padding: `6px 12px`, borderRadius: theme.borderRadius.sm, - border: "none", - background: "none", + border: `1px solid ${theme.colors.border}`, + backgroundColor: theme.colors.card, + fontSize: theme.fontSize.sm, + color: theme.colors.foreground, cursor: "pointer", + fontFamily: "inherit", }, - pageArrowDisabled: { + navButtonDisabled: { opacity: 0.4, cursor: "default", }, + pageNumber: { + width: 32, + height: 32, + borderRadius: theme.borderRadius.sm, + border: `1px solid ${theme.colors.border}`, + backgroundColor: theme.colors.card, + fontSize: theme.fontSize.sm, + color: theme.colors.foreground, + cursor: "pointer", + display: "inline-flex", + alignItems: "center", + justifyContent: "center", + fontFamily: "inherit", + }, + pageNumberActive: { + backgroundColor: theme.colors.primary, + color: theme.colors.primaryForeground, + border: `1px solid ${theme.colors.primary}`, + }, pageInfo: { fontSize: theme.fontSize.sm, color: theme.colors.mutedForeground, diff --git a/packages/react-web/src/components/registry.ts b/packages/react-web/src/components/registry.ts new file mode 100644 index 0000000..ab86555 --- /dev/null +++ b/packages/react-web/src/components/registry.ts @@ -0,0 +1,52 @@ +import type { ComponentType } from "react"; +import type { + ActionButtonProps, + ActionBarProps, + FieldsGridProps, + GroupWrapperProps, + LoadingProps, + DividerProps, + PaginationProps, + ColumnSelectorProps, + EmptyStateProps, + HeaderCellProps, + DataCellProps, + RowActionProps, + CardProps, + SearchBarProps, + TextInputProps, + TextareaInputProps, + SelectInputProps, + DialogButtonProps, +} from "../types"; + +export interface ComponentRegistry { + ActionButton?: ComponentType; + ActionBar?: ComponentType; + FieldsGrid?: ComponentType; + GroupWrapper?: ComponentType; + Loading?: ComponentType; + Divider?: ComponentType; + Pagination?: ComponentType; + ColumnSelector?: ComponentType; + EmptyState?: ComponentType; + HeaderCell?: ComponentType; + DataCell?: ComponentType; + RowAction?: ComponentType; + Card?: ComponentType; + SearchBar?: ComponentType; + TextInput?: ComponentType; + TextareaInput?: ComponentType; + SelectInput?: ComponentType; + DialogButton?: ComponentType; +} + +const globalComponents: ComponentRegistry = {}; + +export function registerComponents(components: Partial): void { + Object.assign(globalComponents, components); +} + +export function getComponent(key: K): ComponentRegistry[K] | undefined { + return globalComponents[key]; +} diff --git a/packages/react-web/src/contracts/component.ts b/packages/react-web/src/contracts/component.ts index a1cbe71..f3a0f83 100644 --- a/packages/react-web/src/contracts/component.ts +++ b/packages/react-web/src/contracts/component.ts @@ -36,6 +36,9 @@ export function createComponent( replace(path: string, params?: Record) { nav(resolvePath(path, params)); }, + open(route: ScopeRoute, params?: Record) { + nav(resolvePath(route.path, params)); + }, }, dialog, toast: { diff --git a/packages/react-web/src/index.ts b/packages/react-web/src/index.ts index e47dc0d..da02cf1 100644 --- a/packages/react-web/src/index.ts +++ b/packages/react-web/src/index.ts @@ -2,13 +2,15 @@ export { DataPage } from './components/DataPage' export { DataForm } from './components/Form' export { DataTable } from './components/Table' export { ActionBar, ActionButton } from './components/ActionBar' +export { registerComponents, getComponent } from './components/registry' +export type { ComponentRegistry } from './components/registry' export { DialogProvider, useDialog } from './components/Dialog' export { ThemeProvider, useTheme } from './theme/context' export { defaultTheme } from './theme/default' export type { Theme } from './theme/default' -export { configureIcons, resolveActionIcon, resolveGroupIcon } from '@ybyra/react' +export { configureIcons, resolveActionIcon, resolveActionLabel, resolveGroupIcon } from '@ybyra/react' export { createComponent, useComponent } from './contracts/component' export { configureI18n } from './i18n' @@ -32,5 +34,10 @@ export type { DataCellProps, RowActionProps, CardProps, + SearchBarProps, + TextInputProps, + TextareaInputProps, + SelectInputProps, + DialogButtonProps, DataTableComponents, } from './types' diff --git a/packages/react-web/src/renderers/DateField.tsx b/packages/react-web/src/renderers/DateField.tsx index a554015..0a6b333 100644 --- a/packages/react-web/src/renderers/DateField.tsx +++ b/packages/react-web/src/renderers/DateField.tsx @@ -3,14 +3,15 @@ import type { FieldRendererProps } from "@ybyra/react"; import { useTheme } from "../theme/context"; import type { Theme } from "../theme/default"; import { ds } from "../support/ds"; +import { resolveFieldLabel } from "../support/i18n"; export function DateField({ domain, name, value, proxy, errors, onChange, onBlur, onFocus }: FieldRendererProps) { - const { t } = useTranslation(); + const { t, i18n } = useTranslation(); const theme = useTheme(); const styles = createStyles(theme); if (proxy.hidden) return null; - const fieldLabel = t(`${domain}.fields.${name}`, { defaultValue: name }); + const fieldLabel = resolveFieldLabel(i18n, t, domain, name); const hasError = errors.length > 0; return ( diff --git a/packages/react-web/src/renderers/ListField.tsx b/packages/react-web/src/renderers/ListField.tsx index 31d80e5..3970e91 100644 --- a/packages/react-web/src/renderers/ListField.tsx +++ b/packages/react-web/src/renderers/ListField.tsx @@ -1,78 +1,122 @@ -import { useState } from "react"; -import { useTranslation } from "react-i18next"; -import type { FieldRendererProps } from "@ybyra/react"; -import { useTheme } from "../theme/context"; -import type { Theme } from "../theme/default"; -import { ds } from "../support/ds"; +import { useMemo, useReducer, useRef } from 'react' +import { createPortal } from 'react-dom' +import type { FieldRendererProps } from '@ybyra/react' +import type { SchemaProvide } from '@ybyra/core' +import { Scope } from '@ybyra/core' +import { DataTable } from '../components/Table' +import { DataForm } from '../components/Form' +import { PageActionsContext, usePageActionsState } from '../components/PageActionsContext' +import { useListDialog } from './list/useListDialog' +import { useListComponent } from './list/useListComponent' +import type { Theme } from '../theme/default' +import { useTheme } from '../theme/context' +import { ds } from '../support/ds' +import { useTranslation } from 'react-i18next' -export function ListField({ domain, name, value, config, proxy, errors, onChange }: FieldRendererProps) { - const { t } = useTranslation(); - const theme = useTheme(); - const styles = createStyles(theme); - const [editIndex, setEditIndex] = useState(null); - if (proxy.hidden) return null; +export function ListField (props: FieldRendererProps) { + const { name, value, config, errors, proxy, onChange, domain } = props - const fieldLabel = t(`${domain}.fields.${name}`, { defaultValue: name }); - const hasError = errors.length > 0; - const items = Array.isArray(value) ? (value as Record[]) : []; - const reorderable = config.attrs.reorderable === true; + const { t } = useTranslation() + const theme = useTheme() + const styles = createStyles(theme) - const remove = (index: number) => { - onChange(items.filter((_, i) => i !== index)); - }; + const schema = config.attrs?.itemSchema as SchemaProvide | undefined + const domainHooks = config.attrs?.hooks as Record | undefined + const domainHandlers = config.attrs?.handlers as Record unknown> | undefined + const domainEvents = config.attrs?.events as any - const add = () => { - onChange([...items, {}]); - }; + const rawValue = Array.isArray(value) ? value : [] + const onChangeRef = useRef(onChange) + onChangeRef.current = onChange - const moveUp = (index: number) => { - if (index === 0) return; - const next = [...items]; - [next[index - 1], next[index]] = [next[index], next[index - 1]]; - onChange(next); - }; + const reactiveValue = useMemo(() => { + const arr = [...rawValue] as Record[] + arr.push = (...items: Record[]) => { + const next = [...rawValue, ...items] + onChangeRef.current(next) + return next.length + } + arr.splice = (start: number, deleteCount?: number, ...items: Record[]) => { + const next = [...rawValue] + const removed = next.splice(start, deleteCount ?? next.length, ...items) + onChangeRef.current(next) + return removed + } + return arr + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [rawValue]) + const fieldLabel = t(`${domain}.fields.${name}`) + const hasError = errors.length > 0 - const moveDown = (index: number) => { - if (index >= items.length - 1) return; - const next = [...items]; - [next[index], next[index + 1]] = [next[index + 1], next[index]]; - onChange(next); - }; + const [dialogState, openDialog, closeDialog] = useListDialog() + const [reloadKey, triggerReload] = useReducer((n: number) => n + 1, 0) + const { node: headerActions, register } = usePageActionsState() + + const tableComponent = useListComponent( + schema!, + rawValue, + openDialog, + closeDialog, + triggerReload, + ) + + const formComponent = useMemo( + () => ({ ...tableComponent, scope: dialogState.scope }), + [tableComponent, dialogState.scope], + ) + + const rawValueRef = useRef(rawValue) + rawValueRef.current = rawValue + const dialogStateRef = useRef(dialogState) + dialogStateRef.current = dialogState + + if (!schema || proxy.hidden) return null return (
-
- {items.map((item, index) => ( -
- #{index + 1} - - {Object.values(item).filter(Boolean).join(", ") || "—"} - -
- {reorderable && ( - <> - - - - )} - {!proxy.disabled && ( - - )} -
+ {headerActions &&
{headerActions}
} + + + + + {dialogState.open && createPortal( +
+
e.stopPropagation()} + > +
- ))} -
- {!proxy.disabled && ( - +
, + document.body, )} -
- {errors.map((error, i) => ( -

{error}

- ))} -
- ); + ) } const createStyles = (theme: Theme) => ({ @@ -80,68 +124,39 @@ const createStyles = (theme: Theme) => ({ padding: `0 ${theme.spacing.xs}px`, }, label: { - display: "block", + display: 'block', fontSize: theme.fontSize.sm, fontWeight: theme.fontWeight.semibold, marginBottom: theme.spacing.xs, color: theme.colors.foreground, }, - labelError: { - color: theme.colors.destructive, - }, - list: { - border: `1px solid ${theme.colors.input}`, - borderRadius: theme.borderRadius.md, - overflow: "hidden", - }, - row: { - display: "flex", - alignItems: "center", - gap: theme.spacing.xs, - padding: `${theme.spacing.xs}px ${theme.spacing.md}px`, - borderBottom: `1px solid ${theme.colors.input}`, - backgroundColor: theme.colors.card, - }, - rowIndex: { - fontSize: theme.fontSize.xs, - color: theme.colors.mutedForeground, - minWidth: 24, - }, - rowPreview: { - flex: 1, - fontSize: theme.fontSize.sm, - color: theme.colors.cardForeground, - overflow: "hidden", - textOverflow: "ellipsis", - whiteSpace: "nowrap" as const, + headerActions: { + display: 'flex', + justifyContent: 'flex-end', + marginBottom: theme.spacing.xs, }, - rowActions: { - display: "flex", - gap: 4, + overlay: { + position: 'fixed' as const, + inset: 0, + backgroundColor: 'rgba(0, 0, 0, 0.5)', + display: 'flex', + justifyContent: 'center', + alignItems: 'center', + zIndex: 9999, }, - btn: { - border: `1px solid ${theme.colors.input}`, - borderRadius: theme.borderRadius.sm, + modal: { backgroundColor: theme.colors.card, - color: theme.colors.cardForeground, - cursor: "pointer", - padding: "2px 6px", - fontSize: theme.fontSize.xs, + borderRadius: theme.borderRadius.xl, + padding: theme.spacing.xxl, + minWidth: 480, + maxWidth: 640, + maxHeight: '90vh', + overflowY: 'auto' as const, + boxShadow: '0 4px 24px rgba(0, 0, 0, 0.2)', }, - btnDestructive: { + labelError: { color: theme.colors.destructive, }, - addBtn: { - marginTop: theme.spacing.xs, - border: `1px dashed ${theme.colors.input}`, - borderRadius: theme.borderRadius.md, - backgroundColor: "transparent", - color: theme.colors.mutedForeground, - cursor: "pointer", - padding: `${theme.spacing.xs}px`, - width: "100%", - fontSize: theme.fontSize.md, - }, errorSlot: { minHeight: 20, marginTop: 2, @@ -151,4 +166,4 @@ const createStyles = (theme: Theme) => ({ color: theme.colors.destructive, margin: 0, }, -}); +}) diff --git a/packages/react-web/src/renderers/MultiSelectField.tsx b/packages/react-web/src/renderers/MultiSelectField.tsx index 6d850e7..1aacd4a 100644 --- a/packages/react-web/src/renderers/MultiSelectField.tsx +++ b/packages/react-web/src/renderers/MultiSelectField.tsx @@ -3,14 +3,15 @@ import type { FieldRendererProps } from "@ybyra/react"; import { useTheme } from "../theme/context"; import type { Theme } from "../theme/default"; import { ds } from "../support/ds"; +import { resolveFieldLabel, resolveFieldOption } from "../support/i18n"; export function MultiSelectField({ domain, name, value, config, proxy, errors, onChange, onBlur, onFocus }: FieldRendererProps) { - const { t } = useTranslation(); + const { t, i18n } = useTranslation(); const theme = useTheme(); const styles = createStyles(theme); if (proxy.hidden) return null; - const fieldLabel = t(`${domain}.fields.${name}`, { defaultValue: name }); + const fieldLabel = resolveFieldLabel(i18n, t, domain, name); const hasError = errors.length > 0; const options = (config.attrs.options ?? []) as (string | number)[]; const selected = Array.isArray(value) ? (value as string[]) : []; @@ -34,7 +35,7 @@ export function MultiSelectField({ domain, name, value, config, proxy, errors, o
{selected.map((v) => ( - {t(`${domain}.fields.${name}.${v}`, { defaultValue: v })} + {resolveFieldOption(i18n, t, domain, name, v)} {!proxy.disabled && ( )} @@ -51,7 +52,7 @@ export function MultiSelectField({ domain, name, value, config, proxy, errors, o onChange={() => toggle(String(opt))} disabled={proxy.disabled} /> - {t(`${domain}.fields.${name}.${opt}`, { defaultValue: String(opt) })} + {resolveFieldOption(i18n, t, domain, name, String(opt))} ))}
diff --git a/packages/react-web/src/renderers/NumberField.tsx b/packages/react-web/src/renderers/NumberField.tsx index 8de0586..0a94f1c 100644 --- a/packages/react-web/src/renderers/NumberField.tsx +++ b/packages/react-web/src/renderers/NumberField.tsx @@ -3,6 +3,7 @@ import type { FieldRendererProps } from "@ybyra/react"; import { useTheme } from "../theme/context"; import type { Theme } from "../theme/default"; import { ds } from "../support/ds"; +import { resolveFieldLabel, resolveFieldPlaceholder } from "../support/i18n"; export function NumberField({ domain, name, value, proxy, errors, onChange, onBlur, onFocus }: FieldRendererProps) { const { t, i18n } = useTranslation(); @@ -10,9 +11,8 @@ export function NumberField({ domain, name, value, proxy, errors, onChange, onBl const styles = createStyles(theme); if (proxy.hidden) return null; - const fieldLabel = t(`${domain}.fields.${name}`, { defaultValue: name }); - const placeholderKey = `${domain}.fields.${name}.placeholder`; - const placeholder = i18n.exists(placeholderKey) ? t(placeholderKey) : undefined; + const fieldLabel = resolveFieldLabel(i18n, t, domain, name); + const placeholder = resolveFieldPlaceholder(i18n, t, domain, name); const hasError = errors.length > 0; return ( diff --git a/packages/react-web/src/renderers/SelectField.tsx b/packages/react-web/src/renderers/SelectField.tsx index 4b31453..86823c6 100644 --- a/packages/react-web/src/renderers/SelectField.tsx +++ b/packages/react-web/src/renderers/SelectField.tsx @@ -3,35 +3,55 @@ import type { FieldRendererProps } from "@ybyra/react"; import { useTheme } from "../theme/context"; import type { Theme } from "../theme/default"; import { ds } from "../support/ds"; +import { resolveFieldLabel, resolveFieldOption, resolveFieldPlaceholder } from "../support/i18n"; +import { getComponent } from "../components/registry"; export function SelectField({ domain, name, value, config, proxy, errors, onChange, onBlur, onFocus }: FieldRendererProps) { - const { t } = useTranslation(); + const { t, i18n } = useTranslation(); const theme = useTheme(); const styles = createStyles(theme); if (proxy.hidden) return null; - const fieldLabel = t(`${domain}.fields.${name}`, { defaultValue: name }); + const fieldLabel = resolveFieldLabel(i18n, t, domain, name); + const placeholder = resolveFieldPlaceholder(i18n, t, domain, name); const hasError = errors.length > 0; - const options = (config.attrs.options ?? []) as (string | number)[]; + const rawOptions = (config.attrs.options ?? []) as (string | number)[]; + const options = rawOptions.map((opt) => ({ + value: String(opt), + label: resolveFieldOption(i18n, t, domain, name, String(opt)), + })); + + const CustomSelect = getComponent('SelectInput'); return (
- + {CustomSelect ? ( + + ) : ( + + )}
{errors.map((error, i) => (

{error}

diff --git a/packages/react-web/src/renderers/TextField.tsx b/packages/react-web/src/renderers/TextField.tsx index 7a92076..423d4d3 100644 --- a/packages/react-web/src/renderers/TextField.tsx +++ b/packages/react-web/src/renderers/TextField.tsx @@ -3,6 +3,8 @@ import type { FieldRendererProps } from "@ybyra/react"; import { useTheme } from "../theme/context"; import type { Theme } from "../theme/default"; import { ds } from "../support/ds"; +import { getComponent } from "../components/registry"; +import { resolveFieldLabel, resolveFieldPlaceholder, resolveFieldDescription } from "../support/i18n"; export function TextField({ domain, name, value, config, proxy, errors, onChange, onBlur, onFocus }: FieldRendererProps) { const { t, i18n } = useTranslation(); @@ -10,25 +12,40 @@ export function TextField({ domain, name, value, config, proxy, errors, onChange const styles = createStyles(theme); if (proxy.hidden) return null; - const fieldLabel = t(`${domain}.fields.${name}`, { defaultValue: name }); - const placeholderKey = `${domain}.fields.${name}.placeholder`; - const placeholder = i18n.exists(placeholderKey) ? t(placeholderKey) : undefined; + const fieldLabel = resolveFieldLabel(i18n, t, domain, name); + const placeholder = resolveFieldPlaceholder(i18n, t, domain, name); + const description = resolveFieldDescription(i18n, t, domain, name); const hasError = errors.length > 0; const inputType = config.kind === "password" ? "password" : "text"; + const CustomInput = getComponent('TextInput'); return (
- onChange(e.target.value)} - onBlur={onBlur} - onFocus={onFocus} - disabled={proxy.disabled} - placeholder={placeholder} - /> + {CustomInput ? ( + + ) : ( + onChange(e.target.value)} + onBlur={onBlur} + onFocus={onFocus} + disabled={proxy.disabled} + placeholder={placeholder} + /> + )} + {description &&

{description}

}
{errors.map((error, i) => (

{error}

@@ -76,6 +93,12 @@ const createStyles = (theme: Theme) => ({ minHeight: 20, marginTop: 2, }, + description: { + fontSize: theme.fontSize.sm, + color: theme.colors.mutedForeground, + marginTop: theme.spacing.xs, + margin: 0, + }, error: { fontSize: theme.fontSize.xs, color: theme.colors.destructive, diff --git a/packages/react-web/src/renderers/TextareaField.tsx b/packages/react-web/src/renderers/TextareaField.tsx index 54c9acd..bbed21b 100644 --- a/packages/react-web/src/renderers/TextareaField.tsx +++ b/packages/react-web/src/renderers/TextareaField.tsx @@ -3,6 +3,8 @@ import type { FieldRendererProps } from "@ybyra/react"; import { useTheme } from "../theme/context"; import type { Theme } from "../theme/default"; import { ds } from "../support/ds"; +import { getComponent } from "../components/registry"; +import { resolveFieldLabel, resolveFieldPlaceholder, resolveFieldDescription } from "../support/i18n"; export function TextareaField({ domain, name, value, config, proxy, errors, onChange, onBlur, onFocus }: FieldRendererProps) { const { t, i18n } = useTranslation(); @@ -10,25 +12,40 @@ export function TextareaField({ domain, name, value, config, proxy, errors, onCh const styles = createStyles(theme); if (proxy.hidden) return null; - const fieldLabel = t(`${domain}.fields.${name}`, { defaultValue: name }); - const placeholderKey = `${domain}.fields.${name}.placeholder`; - const placeholder = i18n.exists(placeholderKey) ? t(placeholderKey) : undefined; + const fieldLabel = resolveFieldLabel(i18n, t, domain, name); + const placeholder = resolveFieldPlaceholder(i18n, t, domain, name); + const description = resolveFieldDescription(i18n, t, domain, name); const hasError = errors.length > 0; const rows = proxy.height || config.form.height || 3; + const CustomInput = getComponent('TextareaInput'); return (
-