diff --git a/build/api/binding.api.md b/build/api/binding.api.md index 9da70aa00..a8d614d62 100644 --- a/build/api/binding.api.md +++ b/build/api/binding.api.md @@ -4,6 +4,8 @@ ```ts +import { ContentEntitySelection } from '@contember/client'; +import { ContentQuery } from '@contember/client'; import { ContentQueryBuilder } from '@contember/client'; import type { CrudQueryBuilder } from '@contember/client'; import { EmbeddedActionsParser } from 'chevrotain'; @@ -319,7 +321,7 @@ export class EntityListAccessor implements Errorable { [Symbol.iterator](): IterableIterator; // Warning: (ae-forgotten-export) The symbol "EntityListState" needs to be exported by the entry point index.d.ts // Warning: (ae-forgotten-export) The symbol "ListOperations" needs to be exported by the entry point index.d.ts - constructor(state: EntityListState, operations: ListOperations, _children: ReadonlyMap, _idsPersistedOnServer: ReadonlySet, hasUnpersistedChanges: boolean, errors: ErrorAccessor | undefined, environment: Environment, getAccessor: EntityListAccessor.GetEntityListAccessor); // (undocumented) @@ -357,6 +359,8 @@ export class EntityListAccessor implements Errorable { // (undocumented) getChildEntityById(id: EntityId): EntityAccessor; // (undocumented) + getMarker(): EntityListSubTreeMarker | HasManyRelationMarker; + // (undocumented) getParent(): EntityAccessor | undefined; // (undocumented) hasEntityId(id: EntityId): boolean; @@ -372,6 +376,8 @@ export class EntityListAccessor implements Errorable { keys(): IterableIterator; // (undocumented) get length(): number; + // (undocumented) + readonly name: EntityName; } // @public (undocumented) @@ -1339,6 +1345,15 @@ export interface QualifiedSingleEntityParameters { where: UniqueWhere; } +// @public (undocumented) +export class QueryGenerator { + constructor(tree: MarkerTreeRoot, qb: ContentQueryBuilder); + // (undocumented) + getReadQuery(): Record>; + // (undocumented) + static registerQueryPart(fields: EntityFieldMarkers, selection: ContentEntitySelection): ContentEntitySelection; +} + // @public (undocumented) export class QueryLanguage { // (undocumented) diff --git a/build/api/react-datagrid-ui.api.md b/build/api/react-datagrid-ui.api.md index cf503ca9c..9a64cb1cf 100644 --- a/build/api/react-datagrid-ui.api.md +++ b/build/api/react-datagrid-ui.api.md @@ -8,7 +8,6 @@ import { BooleanCellRendererProps } from '@contember/react-datagrid'; import { BooleanFilterArtifacts } from '@contember/react-dataview'; import { CoalesceCellRendererProps } from '@contember/react-datagrid'; import { CoalesceFieldViewProps } from '@contember/react-binding-ui'; -import { CoalesceTextFilterArtifacts } from '@contember/react-dataview'; import { ComponentType } from 'react'; import { DataGridColumnCommonProps } from '@contember/react-datagrid'; import { DataGridColumnProps } from '@contember/react-datagrid'; @@ -68,7 +67,7 @@ export const BooleanCellFilter: ({ setFilter, filter }: FilterRendererProps>; // @public diff --git a/build/api/react-datagrid.api.md b/build/api/react-datagrid.api.md index 2869f46e7..e376b5de9 100644 --- a/build/api/react-datagrid.api.md +++ b/build/api/react-datagrid.api.md @@ -6,7 +6,6 @@ import { BaseDynamicChoiceField } from '@contember/react-choice-field'; import { BooleanFilterArtifacts } from '@contember/react-dataview'; -import { CoalesceTextFilterArtifacts } from '@contember/react-dataview'; import { ComponentType } from 'react'; import { DataViewFilterArtifact } from '@contember/react-dataview'; import { DataViewFilterHandler } from '@contember/react-dataview'; @@ -48,12 +47,12 @@ export type BooleanCellRendererProps = { // @public (undocumented) export type CoalesceCellRendererProps = { fields: (SugarableRelativeSingleField | string)[]; - initialFilter?: CoalesceTextFilterArtifacts; + initialFilter?: TextFilterArtifacts; }; // @public (undocumented) export type CoalesceTextCellProps = DataGridColumnCommonProps & CoalesceCellRendererProps & { - initialFilter?: CoalesceTextFilterArtifacts; + initialFilter?: TextFilterArtifacts; }; // @public (undocumented) @@ -76,10 +75,10 @@ export const createBooleanCell: ({ FilterRenderer, ValueRenderer }: { - FilterRenderer: ComponentType>; + FilterRenderer: ComponentType>; ValueRenderer: ComponentType; }) => FunctionComponent; // @public (undocumented) diff --git a/build/api/react-dataview.api.md b/build/api/react-dataview.api.md index 0d5c53328..0fe315652 100644 --- a/build/api/react-dataview.api.md +++ b/build/api/react-dataview.api.md @@ -5,16 +5,22 @@ ```ts import { ChangeEvent } from 'react'; +import { ChildrenAnalyzer } from '@contember/react-multipass-rendering'; import { EntityAccessor } from '@contember/react-binding'; import { EntityAccessor as EntityAccessor_2 } from '@contember/binding'; import { EntityId } from '@contember/react-binding'; import { EntityId as EntityId_2 } from '@contember/binding'; import { EntityListAccessor } from '@contember/binding'; import { EntityListSubTreeLoaderState } from '@contember/react-binding'; +import { EntityListSubTreeMarker } from '@contember/binding'; +import { EntityListSubTreeMarker as EntityListSubTreeMarker_2 } from '@contember/react-binding'; import { Environment } from '@contember/react-binding'; import { Environment as Environment_2 } from '@contember/binding'; +import { FieldMarker } from '@contember/binding'; import { Filter } from '@contember/binding'; import { ForwardRefExoticComponent } from 'react'; +import { HasManyRelationMarker } from '@contember/binding'; +import { HasOneRelationMarker } from '@contember/binding'; import { Input } from '@contember/client'; import { JSX as JSX_2 } from 'react/jsx-runtime'; import { JSXElementConstructor } from 'react'; @@ -28,6 +34,7 @@ import { ReactNode } from 'react'; import { RefAttributes } from 'react'; import { Serializable } from '@contember/react-utils'; import { SetStateAction } from 'react'; +import { StateStorageOrName } from '@contember/react-utils'; import { SugaredOrderBy } from '@contember/binding'; import { SugaredQualifiedEntityList } from '@contember/binding'; import { SugaredRelativeEntityList } from '@contember/binding'; @@ -42,12 +49,6 @@ export type BooleanFilterArtifacts = { nullCondition?: boolean; }; -// @public (undocumented) -export type CoalesceTextFilterArtifacts = { - mode?: 'matches' | 'matchesExactly' | 'startsWith' | 'endsWith' | 'doesNotMatch'; - query?: string; -}; - // @public (undocumented) export const ControlledDataView: NamedExoticComponent< { children: ReactNode; @@ -69,9 +70,6 @@ export type ControlledDataViewProps = { // @public (undocumented) export const createBooleanFilter: (field: SugaredRelativeSingleField['field']) => DataViewFilterHandler; -// @public (undocumented) -export const createCoalesceFilter: (fields: SugaredRelativeSingleField['field'][]) => DataViewFilterHandler; - // @public (undocumented) export const createDateFilter: (field: SugaredRelativeSingleField['field']) => DataViewFilterHandler; @@ -96,6 +94,27 @@ export const createNumberRangeFilter: (field: SugaredRelativeSingleField['field' // @public (undocumented) export const createTextFilter: (field: SugaredRelativeSingleField['field']) => DataViewFilterHandler; +// @public (undocumented) +export const createUnionTextFilter: (fields: SugaredRelativeSingleField['field'][]) => DataViewFilterHandler; + +// @public (undocumented) +export class CsvExportFactory implements ExportFactory { + // (undocumented) + create(args: ExportFormatterCreateOutputArgs): ExportResult; + // (undocumented) + protected createData(data: DataViewDataForExport): string[][]; + // (undocumented) + protected createHeader(data: DataViewDataForExport): string[]; + // (undocumented) + protected filterData(data: DataViewDataForExport): DataViewDataForExport; + // (undocumented) + protected flattenData(data: any[], marker: EntityListSubTreeMarker | HasOneRelationMarker | HasManyRelationMarker): DataViewDataForExport; + // (undocumented) + protected formatOutput(data: DataViewDataForExport): string; + // (undocumented) + protected formatValue(value: any): string; +} + // @public (undocumented) const DataView_2: NamedExoticComponent; export { DataView_2 as DataView } @@ -125,6 +144,12 @@ export interface DataViewChangePageTriggerProps { // @internal (undocumented) export const DataViewCurrentKeyContext: React_2.Context; +// @public (undocumented) +export type DataViewDataForExport = { + markerPath: (EntityListSubTreeMarker | HasOneRelationMarker | HasManyRelationMarker | FieldMarker)[]; + values: any[]; +}[]; + // @public (undocumented) export const DataViewDateFilterInput: ({ name, type, ...props }: { name: string; @@ -191,6 +216,27 @@ export const DataViewEnumFilterTrigger: ({ name, action, value, ...props }: { action?: DataViewSetEnumFilterAction | undefined; }) => JSX_2.Element; +// @public (undocumented) +export const DataViewExportTrigger: ({ fields, children, baseName, exportFactory }: DataViewExportTriggerProps) => JSX_2.Element; + +// @public (undocumented) +export interface DataViewExportTriggerProps { + // (undocumented) + baseName?: string; + // (undocumented) + children: ReactElement; + // (undocumented) + exportFactory?: ExportFactory; + // (undocumented) + fields: ReactNode; +} + +// @public (undocumented) +export const DataViewFilter: ({}: DataViewFilterProps) => never; + +// @public (undocumented) +export const dataViewFilterAnalyzer: ChildrenAnalyzer>; + // @public (undocumented) export type DataViewFilterArtifact = Serializable; @@ -224,21 +270,39 @@ export const DataViewFilteringMethodsContext: React_2.Context DataViewFilteringArtifacts); + filteringStateStorage?: StateStorageOrName; }; // @public (undocumented) export type DataViewFilteringState = { artifact: DataViewFilteringArtifacts; - filter: Filter; + filter: Filter; filterTypes: DataViewFilterHandlerRegistry; }; // @internal (undocumented) export const DataViewFilteringStateContext: React_2.Context; +// @public (undocumented) +export type DataViewFilterProps = { + name: string; + filterHandler: DataViewFilterHandler; +}; + // @internal (undocumented) export const DataViewGlobalKeyContext: React_2.Context; +// @public (undocumented) +export const DataViewHasFilterType: ({ name, children }: DataViewHasFilterTypeProps) => JSX_2.Element | null; + +// @public (undocumented) +export interface DataViewHasFilterTypeProps { + // (undocumented) + children: React.ReactNode; + // (undocumented) + name: string; +} + // @public (undocumented) export const DataViewHasSelection: NamedExoticComponent; @@ -358,8 +422,12 @@ export const DataViewPagingMethodsContext: React_2.Context DataViewSelectionValues); selectionFallback?: DataViewSelectionValue; + selectionStateStorage?: StateStorageOrName; }; // @public (undocumented) @@ -523,6 +592,7 @@ export const DataViewSortingMethodsContext: React_2.Context JSX_2.Element; @@ -622,6 +693,28 @@ export type EnumFilterArtifacts = { nullCondition?: boolean; }; +// @public (undocumented) +export interface ExportFactory { + // (undocumented) + create(args: ExportFormatterCreateOutputArgs): ExportResult; +} + +// @public (undocumented) +export interface ExportFormatterCreateOutputArgs { + // (undocumented) + data: any[]; + // (undocumented) + marker: EntityListSubTreeMarker | HasOneRelationMarker | HasManyRelationMarker; +} + +// @public (undocumented) +export interface ExportResult { + // (undocumented) + blob: Blob; + // (undocumented) + extension: string; +} + // @public (undocumented) export type GenericTextCellFilterArtifacts = { mode?: 'matches' | 'matchesExactly' | 'startsWith' | 'endsWith' | 'doesNotMatch'; @@ -733,6 +826,14 @@ export const useDataViewEnumFilter: (name: string, value: string) => UseDataView // @public (undocumented) export const useDataViewEnumFilterFactory: (name: string) => (value: string) => UseDataViewEnumFilter; +// @public (undocumented) +export const useDataViewFetchAllData: ({ children }: { + children: ReactNode; +}) => () => Promise<{ + data: any; + marker: EntityListSubTreeMarker_2; +}>; + // @public (undocumented) export const useDataViewFilter: (key: string) => [T | undefined, (filter: SetStateAction) => void]; @@ -835,7 +936,10 @@ export const useDataViewSortingMethods: () => DataViewSortingMethods; export const useDataViewSortingState: () => DataViewSortingState; // @public (undocumented) -export const useDataViewTextFilterInput: (name: string) => UseDataViewTextFilterInputResult; +export const useDataViewTextFilterInput: ({ name, debounceMs }: { + name: string; + debounceMs?: number | undefined; +}) => UseDataViewTextFilterInputResult; // @public (undocumented) export interface UseDataViewTextFilterInputResult { diff --git a/build/api/react-select.api.md b/build/api/react-select.api.md index 01ccf2d91..94373db77 100644 --- a/build/api/react-select.api.md +++ b/build/api/react-select.api.md @@ -5,9 +5,12 @@ ```ts import { Context } from 'react'; +import { DataViewFilterHandler } from '@contember/react-dataview'; import { DataViewProps } from '@contember/react-dataview'; import { EntityAccessor } from '@contember/react-binding'; +import { FieldMarker } from '@contember/react-binding'; import { JSX as JSX_2 } from 'react/jsx-runtime'; +import { MeaningfulMarker } from '@contember/react-binding'; import { default as React_2 } from 'react'; import { ReactElement } from 'react'; import { ReactNode } from 'react'; @@ -16,27 +19,28 @@ import { SugaredQualifiedEntityList } from '@contember/react-binding'; import { SugaredRelativeEntityList } from '@contember/react-binding'; import { SugaredRelativeSingleEntity } from '@contember/react-binding'; import { SugaredRelativeSingleField } from '@contember/react-binding'; +import { TextFilterArtifacts } from '@contember/react-dataview'; // @public (undocumented) export const MultiSelect: React_2.NamedExoticComponent<{ children: ReactNode; field: SugaredRelativeEntityList['field']; - options: SugaredQualifiedEntityList['entities']; -} & SelectEvents>; + options?: string | SugarableQualifiedEntityList | undefined; +} & SelectFilterFieldProps & SelectEvents>; // @public (undocumented) export type MultiSelectProps = { children: ReactNode; field: SugaredRelativeEntityList['field']; - options: SugaredQualifiedEntityList['entities']; -} & SelectEvents; + options?: SugaredQualifiedEntityList['entities']; +} & SelectFilterFieldProps & SelectEvents; // @public (undocumented) export const Select: React_2.NamedExoticComponent<{ children: ReactNode; field: SugaredRelativeSingleEntity['field']; - options: SugaredQualifiedEntityList['entities']; -} & SelectEvents>; + options?: string | SugarableQualifiedEntityList | undefined; +} & SelectFilterFieldProps & SelectEvents>; // @internal (undocumented) export const SelectCurrentEntitiesContext: Context; @@ -62,6 +66,11 @@ export interface SelectEvents { onUnselect?: (entity: EntityAccessor) => void; } +// @public (undocumented) +export type SelectFilterFieldProps = { + filterField?: SugaredRelativeSingleField['field'] | SugaredRelativeSingleField['field'][]; +}; + // @public (undocumented) export type SelectHandler = (entity: EntityAccessor, action?: 'select' | 'unselect' | 'toggle') => void; @@ -98,6 +107,9 @@ export type SelectOptionProps = { // @internal (undocumented) export const SelectOptionsContext: Context; +// @internal (undocumented) +export const SelectOptionsFilterContext: Context | undefined>; + // @public (undocumented) export const SelectPlaceholder: ({ children }: { children: ReactNode; @@ -107,30 +119,35 @@ export const SelectPlaceholder: ({ children }: { export type SelectProps = { children: ReactNode; field: SugaredRelativeSingleEntity['field']; - options: SugaredQualifiedEntityList['entities']; -} & SelectEvents; + options?: SugaredQualifiedEntityList['entities']; +} & SelectFilterFieldProps & SelectEvents; // @public (undocumented) export const SortableMultiSelect: React_2.NamedExoticComponent<{ children: ReactNode; field: SugaredRelativeEntityList['field']; - options: SugaredQualifiedEntityList['entities']; + options?: string | SugarableQualifiedEntityList | undefined; sortableBy: SugaredRelativeSingleField['field']; connectAt: SugaredRelativeSingleEntity['field']; -} & SelectEvents>; +} & SelectFilterFieldProps & SelectEvents>; // @public (undocumented) export type SortableMultiSelectProps = { children: ReactNode; field: SugaredRelativeEntityList['field']; - options: SugaredQualifiedEntityList['entities']; + options?: SugaredQualifiedEntityList['entities']; sortableBy: SugaredRelativeSingleField['field']; connectAt: SugaredRelativeSingleEntity['field']; -} & SelectEvents; +} & SelectFilterFieldProps & SelectEvents; // @public (undocumented) export const useSelectCurrentEntities: () => EntityAccessor[]; +// @public (undocumented) +export const useSelectFilter: ({ filterField, marker }: SelectFilterFieldProps & { + marker: Exclude; +}) => DataViewFilterHandler | undefined; + // @public (undocumented) export const useSelectHandleSelect: () => SelectHandler; @@ -140,6 +157,9 @@ export const useSelectIsSelected: () => (entity: EntityAccessor) => boolean; // @public (undocumented) export const useSelectOptions: () => string | SugarableQualifiedEntityList; +// @public (undocumented) +export const useSelectOptionsFilter: () => DataViewFilterHandler | undefined; + // (No @packageDocumentation comment for this package) ``` diff --git a/build/api/react-uploader.api.md b/build/api/react-uploader.api.md index 607654be7..7e557996c 100644 --- a/build/api/react-uploader.api.md +++ b/build/api/react-uploader.api.md @@ -130,8 +130,6 @@ export interface FileType { acceptFile?: ((file: FileWithMeta) => boolean | Promise) | undefined; // (undocumented) extractors?: FileDataExtractor[]; - // Warning: (ae-forgotten-export) The symbol "UploadClient" needs to be exported by the entry point index.d.ts - // // (undocumented) uploader?: UploadClient; } @@ -168,6 +166,22 @@ export interface FileUploadHandlerStaticRenderArgs { environment: Environment_2; } +// @public (undocumented) +export interface FileUploadProgress { + // (undocumented) + progress: number; + // (undocumented) + totalBytes: number; + // (undocumented) + uploadedBytes: number; +} + +// @public (undocumented) +export interface FileUploadResult { + // (undocumented) + publicUrl: string; +} + // @public (undocumented) export interface FileUrlDataExtractorProps { // (undocumented) @@ -237,6 +251,45 @@ type ProgressEvent_2 = { }; export { ProgressEvent_2 as ProgressEvent } +// @public (undocumented) +export interface S3FileOptions { + // (undocumented) + acl?: GenerateUploadUrlMutationBuilder.Acl; + // (undocumented) + contentType?: GenerateUploadUrlMutationBuilder.FileParameters['contentType']; + // (undocumented) + expiration?: number; + // (undocumented) + extension?: string; + // (undocumented) + fileName?: string; + // (undocumented) + prefix?: string; + // (undocumented) + size?: number; + // (undocumented) + suffix?: string; +} + +// @public (undocumented) +export class S3UploadClient implements UploadClient { + constructor(contentApiClient: GraphQlClient, options?: S3UploadClientOptions); + // (undocumented) + readonly options: S3UploadClientOptions; + // (undocumented) + upload({ file, signal, onProgress, ...options }: UploadClientUploadArgs & S3FileOptions): Promise<{ + publicUrl: string; + }>; +} + +// @public (undocumented) +export interface S3UploadClientOptions { + // (undocumented) + concurrency?: number; + // (undocumented) + getUploadOptions?: (file: File) => S3FileOptions; +} + // @public (undocumented) export type StartUploadEvent = { file: FileWithMeta; @@ -250,6 +303,23 @@ export type SuccessEvent = { fileType: FileType; }; +// @public (undocumented) +export interface UploadClient { + // (undocumented) + upload: (args: UploadClientUploadArgs & Omit) => Promise; +} + +// @public (undocumented) +export interface UploadClientUploadArgs { + // (undocumented) + file: File; + // (undocumented) + onProgress: (progress: FileUploadProgress) => void; + // (undocumented) + signal: AbortSignal; +} + // @public (undocumented) export const Uploader: NamedExoticComponent; @@ -377,8 +447,16 @@ export const UploaderHasFile: ({ children, state, fallback }: { state?: "initial" | "uploading" | "finalizing" | "success" | "error" | ("initial" | "uploading" | "finalizing" | "success" | "error")[] | undefined; }) => JSX_2.Element; -// Warning: (ae-forgotten-export) The symbol "UploaderOptions" needs to be exported by the entry point index.d.ts -// +// @public (undocumented) +export interface UploaderOptions { + // (undocumented) + accept: { + [key: string]: string[]; + } | undefined; + // (undocumented) + multiple: boolean; +} + // @public (undocumented) export const UploaderOptionsContext: Context; @@ -397,8 +475,6 @@ export const UploaderStateContext: Context; // @public (undocumented) export const UploaderUploadFilesContext: Context<(files: File[]) => void>; -// Warning: (ae-forgotten-export) The symbol "S3UploadClient" needs to be exported by the entry point index.d.ts -// // @public (undocumented) export const useS3Client: () => S3UploadClient; @@ -441,11 +517,6 @@ export interface VideoFileDataExtractorProps { // @public (undocumented) export type VideoFileTypeProps = FileType & FileUrlDataExtractorProps & GenericFileMetadataExtractorProps & VideoFileDataExtractorProps; -// Warnings were encountered during analysis: -// -// src/types/events.ts:17:2 - (ae-forgotten-export) The symbol "FileUploadProgress" needs to be exported by the entry point index.d.ts -// src/types/events.ts:29:2 - (ae-forgotten-export) The symbol "FileUploadResult" needs to be exported by the entry point index.d.ts - // (No @packageDocumentation comment for this package) ``` diff --git a/build/api/react-utils.api.md b/build/api/react-utils.api.md index 815b5ed5e..f7e35185b 100644 --- a/build/api/react-utils.api.md +++ b/build/api/react-utils.api.md @@ -90,6 +90,9 @@ export const identityFunction: (value: Value) => Value; // @public (undocumented) export function isNoopScopedConsole(value: unknown): boolean; +// @public (undocumented) +export const localStateStorage: StateStorage; + // @public (undocumented) export type MaybeRef = ((instance: T | null) => void) | MutableRefObject | null | undefined; @@ -105,6 +108,9 @@ export function noopLogged(message: string, value: T): T; // @public (undocumented) export const noopScopedConsole: ScopedConsoleContextType; +// @public (undocumented) +export const nullStorage: StateStorage; + // @public (undocumented) export type Offsets = { bottom: number; @@ -150,12 +156,32 @@ export type Serializable = string | number | boolean | null | readonly Serializa readonly [K in string]?: Serializable; }; +// @public (undocumented) +export const sessionStateStorage: StateStorage; + // @public (undocumented) export type SetState = (value: V | ((current: V) => V)) => void; +// @public (undocumented) +export interface StateStorage { + // (undocumented) + getItem(key: StateStorageKey): Serializable; + // (undocumented) + setItem(key: StateStorageKey, value: Serializable): void; +} + +// @public (undocumented) +export type StateStorageKey = [uniquePrefix: string, key: string]; + +// @public (undocumented) +export type StateStorageOrName = StateStorage | 'url' | 'session' | 'local' | 'null'; + // @public export function unwrapRefValue(value: RefObjectOrElement): T | null; +// @public (undocumented) +export const urlStateStorage: StateStorage; + // @public (undocumented) export const useAbortController: () => () => AbortSignal; @@ -241,6 +267,9 @@ export function useId(): string; // @public (undocumented) export const useIsMounted: () => MutableRefObject; +// @public (undocumented) +export const useLocalStorageState: (key: StateStorageKey, initializeValue: ValueInitializer) => [V, SetState]; + // @public (undocumented) export const useObjectMemo: (value: A) => A; @@ -280,7 +309,10 @@ export function useScrollOffsets(refOrElement: RefObjectOrElement(key: string, initializeValue: ValueInitializer) => [V, SetState]; +export const useSessionStorageState: (key: StateStorageKey, initializeValue: ValueInitializer) => [V, SetState]; + +// @public (undocumented) +export const useStoredState: (storageOrName: StateStorageOrName | StateStorageOrName[], key: StateStorageKey, initializeValue: ValueInitializer) => [V, SetState]; // @internal export function useThemedClassName(componentClassName: NestedClassName, additionalClassName: NestedClassName, prefixOverride?: string | null | undefined): string; diff --git a/packages/admin-sandbox/admin/components/Layout.tsx b/packages/admin-sandbox/admin/components/Layout.tsx index 036be35b0..79f25cbf4 100644 --- a/packages/admin-sandbox/admin/components/Layout.tsx +++ b/packages/admin-sandbox/admin/components/Layout.tsx @@ -19,7 +19,7 @@ export const Layout = memo(({ children }: PropsWithChildren) => { const width = useContainerWidth() const [scheme, setScheme] = useSessionStorageState( - 'contember-admin-sandbox-scheme', + ['', 'contember-admin-sandbox-scheme'], scheme => scheme ?? 'system', ) diff --git a/packages/binding/src/accessors/EntityListAccessor.ts b/packages/binding/src/accessors/EntityListAccessor.ts index 3b15f309f..34436b3f7 100644 --- a/packages/binding/src/accessors/EntityListAccessor.ts +++ b/packages/binding/src/accessors/EntityListAccessor.ts @@ -1,6 +1,6 @@ import type { ListOperations } from '../core/operations' import type { Environment } from '../dao' -import type { EntityId, EntityRealmKey } from '../treeParameters' +import type { EntityId, EntityName, EntityRealmKey } from '../treeParameters' import type { AsyncBatchUpdatesOptions } from './AsyncBatchUpdatesOptions' import type { BatchUpdatesOptions } from './BatchUpdatesOptions' import type { EntityAccessor } from './EntityAccessor' @@ -10,11 +10,13 @@ import type { PersistErrorOptions } from './PersistErrorOptions' import type { PersistSuccessOptions } from './PersistSuccessOptions' import type { EntityListState } from '../core/state' import { RuntimeId } from '../accessorTree' +import { EntityListSubTreeMarker, HasManyRelationMarker } from '../markers' class EntityListAccessor implements Errorable { public constructor( private readonly state: EntityListState, private readonly operations: ListOperations, + public readonly name: EntityName, private readonly _children: ReadonlyMap, private readonly _idsPersistedOnServer: ReadonlySet, public readonly hasUnpersistedChanges: boolean, @@ -133,6 +135,10 @@ class EntityListAccessor implements Errorable { public getParent(): EntityAccessor | undefined { return this.state.blueprint.parent?.getAccessor() } + + public getMarker(): EntityListSubTreeMarker | HasManyRelationMarker { + return this.state.blueprint.marker + } } namespace EntityListAccessor { diff --git a/packages/binding/src/core/StateInitializer.ts b/packages/binding/src/core/StateInitializer.ts index f85af6a47..ad34334e1 100644 --- a/packages/binding/src/core/StateInitializer.ts +++ b/packages/binding/src/core/StateInitializer.ts @@ -317,6 +317,7 @@ export class StateInitializer { entityListState.accessor = new EntityListAccessor( entityListState, this.listOperations, + entityListState.entityName, entityListState.children, persistedEntityIds, entityListState.unpersistedChangesCount !== 0, diff --git a/packages/binding/src/core/index.ts b/packages/binding/src/core/index.ts index 2913650c6..c90466746 100644 --- a/packages/binding/src/core/index.ts +++ b/packages/binding/src/core/index.ts @@ -4,6 +4,7 @@ export * from './DataBinding' export * from './DirtinessTracker' export * from './EventManager' export * from './MarkerMerger' +export * from './QueryGenerator' export * from './StateInitializer' export * from './TreeAugmenter' export * from './TreeStore' diff --git a/packages/playground/admin/app/components/navigation.tsx b/packages/playground/admin/app/components/navigation.tsx index 471cfada1..d1f3eaed3 100644 --- a/packages/playground/admin/app/components/navigation.tsx +++ b/packages/playground/admin/app/components/navigation.tsx @@ -1,150 +1,40 @@ import { ArchiveIcon, BrushIcon, FormInputIcon, GripVertical, HomeIcon, KanbanIcon, TableIcon, UploadIcon } from 'lucide-react' -import { MenuList } from '../../lib/components/ui/menu' +import { Menu, MenuItem, MenuList } from '../../lib/components/ui/menu' export const Navigation = () => { const line =   return (
- , - label: 'Home', - to: 'index', - }, - { - icon: , - label: 'UI', - subItems: [ - { - icon: line, - label: 'Buttons', - to: 'ui/button', - }, - { - icon: line, - label: 'Toasts', - to: 'ui/toast', - }, - ], - }, - { - icon: , - label: 'Kanban', - subItems: [ - { - icon: line, - label: 'Dynamic columns', - to: 'board/assignee', - }, - { - icon: line, - label: 'Static columns', - to: 'board/status', - }, - ], - }, - { - icon: , - label: 'Repeater', - to: 'repeater', - }, - { - icon: , - label: 'Grid', - to: 'grid', - }, - { - icon: , - label: 'Inputs', - subItems: [ - { - icon: line, - label: 'Basic inputs', - to: 'input/basic', - }, - { - icon: line, - label: 'Textarea', - to: 'input/textarea', - }, - { - icon: line, - label: 'Client validation', - to: 'input/clientValidation', - }, - { - icon: line, - label: 'Checkbox', - to: 'input/checkbox', - }, - { - icon: line, - label: 'Radio', - to: 'input/enumRadio', - }, - ], - }, - { - icon: , - label: 'Select', - subItems: [ - { - icon: line, - label: 'Has one select', - to: 'select/hasOne', - }, - { - icon: line, - label: 'Has many select', - to: 'select/hasMany', - }, - { - icon: line, - label: 'Has many sortable select', - to: 'select/hasManySortable', - }, - ], - }, - { - icon: , - label: 'Upload', - subItems: [ - { - icon: line, - label: 'Image upload', - to: 'upload/image', - }, - { - icon: line, - label: 'Image w/o meta', - to: 'upload/imageTrivial', - }, - { - icon: line, - label: 'Audio upload', - to: 'upload/audio', - }, - { - icon: line, - label: 'Video upload', - to: 'upload/video', - }, - { - icon: line, - label: 'Generic file upload', - to: 'upload/any', - }, - { - icon: line, - label: 'Image repeater', - to: 'upload/imageList', - }, - ], - }, - ]} - /> + + } label={'Home'} to={'index'} /> + } label={'UI'}> + + + + } label={'Kanban'}> + + + + } label={'Repeater'} to={'repeater'} /> + } label={'Grid'}> + + + + } label={'Inputs'}> + + + + + + + + } label={'Select'}> + + + + +
) } diff --git a/packages/playground/admin/app/pages/board.tsx b/packages/playground/admin/app/pages/board.tsx index 55b95671f..6de9e4732 100644 --- a/packages/playground/admin/app/pages/board.tsx +++ b/packages/playground/admin/app/pages/board.tsx @@ -1,7 +1,7 @@ import { Field } from '@contember/interface' import { Slots } from '../../lib/components/slots' import { BoardColumnLabel } from '@contember/react-board' -import { Binding, PersistButton } from '../../lib/components/binding' +import { Binding, PersistButton, PersistOnFieldChange } from '../../lib/components/binding' import { DefaultBoard } from '../../lib/components/board' const statusList = [ @@ -47,6 +47,7 @@ export const status = () => <> + <> } > + + diff --git a/packages/playground/admin/app/pages/grid.tsx b/packages/playground/admin/app/pages/grid.tsx index 72770e81c..9b3aed223 100644 --- a/packages/playground/admin/app/pages/grid.tsx +++ b/packages/playground/admin/app/pages/grid.tsx @@ -1,11 +1,28 @@ -import { Component, Field, HasMany } from '@contember/interface' +import { Component, Field, HasMany, If } from '@contember/interface' import { Slots } from '../../lib/components/slots' -import { DataViewHasSelection } from '@contember/react-dataview' -import { DataViewColumns, DataViewRelationFieldTooltip, DefaultDataGrid } from '../../lib/components/datagrid' +import { createHasManyFilter, DataView, DataViewEachRow, DataViewHasSelection } from '@contember/react-dataview' +import { + DataGrid, + DataGridColumns, + DataGridDateFilter, + DataGridEnumFilter, + DataGridHasManyFilter, + DataGridHasOneFilter, + DataGridLoader, + DataGridPagination, + DataGridPerPageSelector, + DataGridRelationFieldTooltip, + DataGridTextFilter, +} from '../../lib/components/datagrid' import * as React from 'react' import { DefaultDropdown, DropdownMenuItem, DropdownMenuSeparator } from '../../lib/components/ui/dropdown' import { Binding, DeleteEntityDialog } from '../../lib/components/binding' import { GridArticleStateLabels } from '../labels' +import { formatDate } from '../../lib/utils/formatting' +import { Button } from '../../lib/components/ui/button' +import { EyeIcon, LockIcon, MessageSquareIcon, SettingsIcon } from 'lucide-react' +import { Popover, PopoverContent, PopoverTrigger } from '../../lib/components/ui/popover' +import { DataGridToolbarVisibleFields } from '../../lib/components/datagrid/columns-hiding' const GridDropdown = () => ( @@ -33,9 +50,11 @@ const GridTile = Component(() => (
- - - + + +
@@ -52,21 +71,138 @@ export default () => ( - } lastColumnActions={} + initialSorting={{ + publishedAt: 'asc', + }} columns={[ - DataViewColumns.text({ field: 'title', label: 'Title' }), - DataViewColumns.enum({ field: 'state', label: 'State', options: GridArticleStateLabels }), - DataViewColumns.date({ field: 'publishedAt', label: 'Published at' }), - DataViewColumns.hasOne({ field: 'author', valueField: 'name', label: 'Author', filterOptions: 'GridAuthor' }), - DataViewColumns.hasOne({ field: 'category', valueField: 'name', label: 'Category', filterOptions: 'GridCategory' }), - DataViewColumns.hasMany({ field: 'tags', valueField: 'name', label: 'Tags', filterOptions: 'GridTag' }), - DataViewColumns.boolean({ field: 'locked', label: 'Locked' }), - DataViewColumns.number({ field: 'views', label: 'Views' }), + DataGridColumns.text({ field: 'title', label: 'Title' }), + DataGridColumns.enum({ field: 'state', label: 'State', options: GridArticleStateLabels }), + DataGridColumns.date({ field: 'publishedAt', label: 'Published at' }), + DataGridColumns.hasOne({ field: 'author', valueField: 'name', label: 'Author', filterOptions: 'GridAuthor' }), + DataGridColumns.hasOne({ field: 'category', valueField: 'name', label: 'Category', filterOptions: 'GridCategory' }), + DataGridColumns.hasMany({ field: 'tags', valueField: 'name', label: 'Tags', filterOptions: 'GridTag' }), + DataGridColumns.hasMany({ field: 'comments', valueField: 'author.name', label: 'Comment authors', filterOptions: 'GridAuthor', filterHandler: createHasManyFilter('comments.author'), filterOption: }), + DataGridColumns.boolean({ field: 'locked', label: 'Locked' }), + DataGridColumns.number({ field: 'views', label: 'Views' }), ]} /> + + +) + +const CustomGridRow = Component(() => ( +
+
+
+ +
+ + + +
+
+
+ + + + + + + + + + + + + + + +
+
+ published by +
+
+
+ +
+ + field="views" format={it => it ?? 0} /> +
+
+ + accessor.length} /> +
+
+
+)) + +export const customGrid = () => ( + <> + +

+ Articles +

+
+ + + + +
+
+
+ + + + + + + + + + + + +
+
+ + + + + +
+ + +
+ +
+
+
+
+ + + + + + + +
+ + + +
diff --git a/packages/playground/admin/app/pages/input.tsx b/packages/playground/admin/app/pages/input.tsx index 73a262d4a..44b4c6631 100644 --- a/packages/playground/admin/app/pages/input.tsx +++ b/packages/playground/admin/app/pages/input.tsx @@ -4,6 +4,7 @@ import { CheckboxField, InputField, RadioEnumField, TextareaField } from '../../ import * as React from 'react' import { Button } from '../../lib/components/ui/button' import { Binding, PersistButton } from '../../lib/components/binding' +import { SelectOrTypeField } from '../../lib-extra/select-or-type-field' export const basic = () => <> @@ -23,6 +24,23 @@ export const basic = () => <> + +export const selectOrType = () => <> + + + + + +
+ +
+
+
+ + export const textarea = () => <> diff --git a/packages/playground/admin/app/pages/select.tsx b/packages/playground/admin/app/pages/select.tsx index c6cd148db..28f2d6ae3 100644 --- a/packages/playground/admin/app/pages/select.tsx +++ b/packages/playground/admin/app/pages/select.tsx @@ -13,10 +13,7 @@ export const hasOne = () => <>
- - - - }> +
@@ -30,7 +27,7 @@ export const hasMany = () => <>
- +
@@ -44,10 +41,32 @@ export const hasManySortable = () => <>
- +
+ +export const createNewForm = () => <> + + + + + +
+ + + + } + > + + +
+
+
+ diff --git a/packages/playground/admin/lib-extra/select-or-type-field.tsx b/packages/playground/admin/lib-extra/select-or-type-field.tsx new file mode 100644 index 000000000..6d4fa52ad --- /dev/null +++ b/packages/playground/admin/lib-extra/select-or-type-field.tsx @@ -0,0 +1,59 @@ +import { FormContainer, FormContainerProps } from '../lib/components/form' +import { ComponentProps, useState } from 'react' +import { Input, SelectInput } from '../lib/components/ui/input' +import { cn } from '../lib/utils/cn' +import * as React from 'react' +import { FormFieldScope, FormInput, FormInputProps } from '@contember/react-form' +import { Component, Field } from '@contember/react-binding' +import { useField } from '@contember/react-binding' + +export type SelectOrTypeFieldProps = + & Omit + & Omit + & { + required?: boolean + selectProps?: ComponentProps + inputProps?: ComponentProps + options: Record + } + +export const SelectOrTypeField = Component(({ field, label, description, selectProps, inputProps, isNonbearing, defaultValue, required, options }: SelectOrTypeFieldProps) => { + const fieldAccessor = useField({ field }) + const fieldValue = fieldAccessor.value + const [showSelect, setShowSelect] = useState(!fieldValue || Object.keys(options).includes(fieldValue)) + return ( + + + {showSelect ? { + const value = e.target.value + if (value === '___other') { + setShowSelect(false) + fieldAccessor.updateValue('') + } else { + fieldAccessor.updateValue(value) + + } + }} {...selectProps} className={cn('max-w-md', selectProps?.className)}> + + {Object.entries(options ?? {}).map(([value, label]) => ( + + ))} + + + : + + } + + + ) +}, ({ field, label, description, isNonbearing, defaultValue }) => { + return ( + <> + {label} + {description} + + + ) +}) diff --git a/packages/playground/admin/lib/components/binding/persist.tsx b/packages/playground/admin/lib/components/binding/persist.tsx index a891cd63f..0a86ab378 100644 --- a/packages/playground/admin/lib/components/binding/persist.tsx +++ b/packages/playground/admin/lib/components/binding/persist.tsx @@ -1,4 +1,4 @@ -import { PersistTrigger } from '@contember/interface' +import { PersistTrigger, SugaredRelativeSingleField, useField } from '@contember/interface' import { Button } from '../ui/button' import { Loader } from '../ui/loader' import { ReactElement, ReactNode, useCallback, useEffect } from 'react' @@ -108,3 +108,29 @@ export const PersistOnCmdS = () => { return null } + + +export const PersistOnFieldChange = ({ field }: { + field: SugaredRelativeSingleField['field'] +}) => { + const triggerPersist = usePersist() + const onError = usePersistErrorHandler() + const getField = useField(field).getAccessor + useEffect(() => { + let persisting = false + return getField().addEventListener({ type: 'update' }, async field => { + if (persisting || field.value === field.valueOnServer) { + return + } + persisting = true + try { + await Promise.resolve() + await triggerPersist().catch(onError) + } finally { + persisting = false + } + }) + }, [getField, onError, triggerPersist]) + + return null +} diff --git a/packages/playground/admin/lib/components/datagrid/column-header.tsx b/packages/playground/admin/lib/components/datagrid/column-header.tsx index e28831a36..a07087884 100644 --- a/packages/playground/admin/lib/components/datagrid/column-header.tsx +++ b/packages/playground/admin/lib/components/datagrid/column-header.tsx @@ -7,7 +7,7 @@ import { DataViewSelectionTrigger, DataViewSortingSwitch, DataViewSortingTrigger import { dict } from '../../dict' -export function DataViewColumnHeader({ sortingField, hidingName, children }: { +export function DataGridColumnHeader({ sortingField, hidingName, children }: { sortingField?: string, hidingName?: string, children: ReactNode, diff --git a/packages/playground/admin/lib/components/datagrid/columns-hiding.tsx b/packages/playground/admin/lib/components/datagrid/columns-hiding.tsx new file mode 100644 index 000000000..86a53e307 --- /dev/null +++ b/packages/playground/admin/lib/components/datagrid/columns-hiding.tsx @@ -0,0 +1,34 @@ +import { EyeIcon, EyeOffIcon } from 'lucide-react' +import { dict } from '../../dict' +import * as React from 'react' +import { ReactNode } from 'react' +import { DataViewSelectionTrigger } from '@contember/react-dataview' +import { ScrollArea } from '../ui/scroll-area' + +export interface DataGridToolbarVisibleFields { + fields: { + name: string + header: ReactNode + }[] +} + +export const DataGridToolbarVisibleFields = ({ fields }: DataGridToolbarVisibleFields) => { + return
+

{dict.datagrid.visibleFields}

+
+ +
+ {fields.map(field => ( + field.name && !(it ?? true)}> + + + ))} +
+
+
+
+} diff --git a/packages/playground/admin/lib/components/datagrid/columns.tsx b/packages/playground/admin/lib/components/datagrid/columns.tsx index 7d1a4e119..83dd85358 100644 --- a/packages/playground/admin/lib/components/datagrid/columns.tsx +++ b/packages/playground/admin/lib/components/datagrid/columns.tsx @@ -1,27 +1,13 @@ import * as React from 'react' import { ReactNode } from 'react' -import { - DataViewEnumFieldTooltip, - DataViewRelationFieldTooltip, - DataViewTextFilter, DefaultDataViewBooleanFilter, DefaultDataViewDateFilter, - DefaultDataViewEnumFilter, - DefaultDataViewNumberFilter, - DefaultDataViewRelationFilter, -} from './filters' +import { DataGridBooleanFilter, DataGridDateFilter, DataGridEnumFieldTooltip, DataGridEnumFilter, DataGridHasManyFilter, DataGridHasOneFilter, DataGridNumberFilter, DataGridRelationFieldTooltip } from './filters' import { formatBoolean, formatDate, formatNumber } from '../../utils/formatting' -import { DataViewColumn } from './grid' +import { DataGridColumn } from './grid' import { Field, HasMany, HasOne } from '@contember/react-binding' -import { - createBooleanFilter, - createDateFilter, - createEnumFilter, - createHasManyFilter, - createHasOneFilter, - createNumberRangeFilter, - createTextFilter, - DataViewFilterHandler, -} from '@contember/react-dataview' +import { createHasManyFilter, DataViewFilterHandler } from '@contember/react-dataview' import { SugaredQualifiedEntityList } from '@contember/interface' +import { Button } from '../ui/button' +import { DataGridTooltipLabel } from './ui' export interface DataViewColumnCommonArgs { field: string @@ -40,16 +26,15 @@ export type DataViewTextColumnArgs = sortingField?: string } -export const createTextColumn = ({ field, label, ...args }: DataViewTextColumnArgs): DataViewColumn => { - +export const createTextColumn = ({ field, label, ...args }: DataViewTextColumnArgs): DataGridColumn => { return { + type: 'text', + field, filterName: field, cell: , header: label, hidingName: field, sortingField: field, - filterHandler: createTextFilter(field), - filterToolbar: , ...args, } } @@ -72,35 +57,38 @@ export type DataViewHasOneColumnArgs = sortingField?: string } -export const createHasOneColumn = ({ field, label, tooltipActions, filterOptions, valueField, value, filterLabel, filterField, filterOption, ...args }: DataViewHasOneColumnArgs): DataViewColumn => { +export const createHasOneColumn = ({ field, label, tooltipActions, filterOptions, valueField, value, filterLabel, filterField, filterOption, ...args }: DataViewHasOneColumnArgs): DataGridColumn => { value ??= valueField ? : null filterOption ??= value return { + type: 'hasOne', + field, filterName: field, cell: ( -
+
- - {value} - + + + {value} + +
), header: label, hidingName: field, sortingField: valueField ? field + '.' + valueField : undefined, - filterHandler: createHasOneFilter(field), filterToolbar: filterOptions && ( - {filterOption} - + ), ...args, } @@ -111,17 +99,21 @@ export type DataViewHasManyColumnArgs = & DataViewRelationColumnArgs -export const createHasManyColumn = ({ field, label, tooltipActions, filterOptions, valueField, value, filterLabel, filterField, filterOption, ...args }: DataViewHasManyColumnArgs): DataViewColumn => { - value ??= +export const createHasManyColumn = ({ field, label, tooltipActions, filterOptions, valueField, value, filterLabel, filterField, filterOption, ...args }: DataViewHasManyColumnArgs): DataGridColumn => { + value ??= valueField ? : null filterOption ??= value return { + type: 'hasMany', + field, filterName: field, cell: ( -
+
- - {value} - + + + {value} + +
), @@ -129,14 +121,14 @@ export const createHasManyColumn = ({ field, label, tooltipActions, filterOption hidingName: field, filterHandler: createHasManyFilter(field), filterToolbar: filterOptions && ( - {filterOption} - + ), ...args, } @@ -148,15 +140,16 @@ export type DataViewNumberColumnArgs = sortingField?: string } -export const createNumberColumn = ({ field, label, ...args }: DataViewNumberColumnArgs): DataViewColumn => { +export const createNumberColumn = ({ field, label, ...args }: DataViewNumberColumnArgs): DataGridColumn => { return { + type: 'number', + field, filterName: field, cell: , header: label, hidingName: field, sortingField: field, - filterHandler: createNumberRangeFilter(field), - filterToolbar: , + filterToolbar: , ...args, } } @@ -167,15 +160,16 @@ export type DataViewDateColumnArgs = sortingField?: string } -export const createDateColumn = ({ field, label, ...args }: DataViewDateColumnArgs): DataViewColumn => { +export const createDateColumn = ({ field, label, ...args }: DataViewDateColumnArgs): DataGridColumn => { return { + type: 'date', + field, filterName: field, cell: , header: label, hidingName: field, sortingField: field, - filterHandler: createDateFilter(field), - filterToolbar: , + filterToolbar: , ...args, } } @@ -186,16 +180,17 @@ export type DataViewBooleanColumnArgs = sortingField?: string } -export const createBooleanColumn = ({ field, label, ...args }: DataViewBooleanColumnArgs): DataViewColumn => { +export const createBooleanColumn = ({ field, label, ...args }: DataViewBooleanColumnArgs): DataGridColumn => { return { + type: 'boolean', + field, filterName: field, cell: , header: label, hidingName: field, sortingField: field, - filterHandler: createBooleanFilter(field), - filterToolbar: , + filterToolbar: , ...args, } } @@ -208,25 +203,28 @@ export type DataViewEnumColumnArgs = filterLabel?: ReactNode } -export const createEnumColumn = ({ field, label, options, filterLabel, ...args }: DataViewEnumColumnArgs): DataViewColumn => { +export const createEnumColumn = ({ field, label, options, filterLabel, ...args }: DataViewEnumColumnArgs): DataGridColumn => { return { + type: 'enum', + field, filterName: field, cell: (
field={field} format={it => it ? ( - - {options[it]} - - ) : null} - /> + + + {options[it]} + + + ) : null} + />
), header: label, hidingName: field, sortingField: field, - filterHandler: createEnumFilter(field), filterToolbar: ( - @@ -234,7 +232,7 @@ export const createEnumColumn = ({ field, label, options, filterLabel, ...args } ...args, } } -export const DataViewColumns = { +export const DataGridColumns = { text: createTextColumn, number: createNumberColumn, date: createDateColumn, diff --git a/packages/playground/admin/lib/components/datagrid/empty.tsx b/packages/playground/admin/lib/components/datagrid/empty.tsx index 15a7bb0c2..47b3912da 100644 --- a/packages/playground/admin/lib/components/datagrid/empty.tsx +++ b/packages/playground/admin/lib/components/datagrid/empty.tsx @@ -1,7 +1,7 @@ import * as React from 'react' import { dict } from '../../dict' -export const DataViewNoResults = () => ( +export const DataGridNoResults = () => (
{dict.datagrid.empty}
diff --git a/packages/playground/admin/lib/components/datagrid/export.tsx b/packages/playground/admin/lib/components/datagrid/export.tsx new file mode 100644 index 000000000..8470ef504 --- /dev/null +++ b/packages/playground/admin/lib/components/datagrid/export.tsx @@ -0,0 +1,18 @@ +import { Button } from '../ui/button' +import { DownloadIcon } from 'lucide-react' +import { DataViewExportTrigger } from '@contember/react-dataview' +import * as React from 'react' +import { useMemo } from 'react' +import { DataGridColumn } from './grid' + +export const DataGridAutoExport = ({ columns }: { columns: DataGridColumn[] }) => { + const children = useMemo(() => <>{columns.map(it => it.cell)}, [columns]) + return ( + + + + ) +} diff --git a/packages/playground/admin/lib/components/datagrid/filters/boolean.tsx b/packages/playground/admin/lib/components/datagrid/filters/boolean.tsx index e6780bc5f..7eeef9eb8 100644 --- a/packages/playground/admin/lib/components/datagrid/filters/boolean.tsx +++ b/packages/playground/admin/lib/components/datagrid/filters/boolean.tsx @@ -1,41 +1,63 @@ import * as React from 'react' import { ReactNode } from 'react' -import { DataViewBooleanFilterTrigger, DataViewNullFilterTrigger } from '@contember/react-dataview' +import { createBooleanFilter, DataViewBooleanFilterTrigger, DataViewFilter, DataViewNullFilterTrigger } from '@contember/react-dataview' import { Popover, PopoverContent, PopoverTrigger } from '../../ui/popover' -import { DataViewActiveFilterUI, DataViewFilterSelectTriggerUI, DataViewSingleFilterUI } from '../ui' -import { DataViewNullFilter } from './common' +import { DataGridActiveFilterUI, DataGridFilterSelectTriggerUI, DataGridSingleFilterUI } from '../ui' +import { DataGridNullFilter } from './common' import { formatBoolean } from '../../../utils/formatting' import { Button } from '../../ui/button' import { dict } from '../../../dict' +import { SugaredRelativeSingleField } from '@contember/binding' +import { Component } from '@contember/interface' +import { getFilterName } from './utils' -export const DataViewBooleanFilterList = ({ name }: { +type DataGridBooleanFilterProps = { + field: SugaredRelativeSingleField['field'] + name?: string + label: ReactNode +} + +export const DataGridBooleanFilter = Component(({ name: nameIn, field, label }: DataGridBooleanFilterProps) => { + const name = getFilterName(nameIn, field) + return ( + + + + + ) +}, ({ name, field }) => { + return +}) + +export const DataGridBooleanFilterList = ({ name }: { name: string }) => ( <> {[true, false].map(value => ( - + {formatBoolean(value)} - + ))} - + {dict.datagrid.na} - + ) -export const DataViewBooleanFilterSelect = ({ name, label }: { + +export const DataGridBooleanFilterSelect = ({ name, label }: { name: string label?: ReactNode }) => ( - {label} + {label}
@@ -52,19 +74,8 @@ export const DataViewBooleanFilterSelect = ({ name, label }: { ))}
- +
) - - -export const DefaultDataViewBooleanFilter = ({ name, label }: { - name: string - label: ReactNode -}) => ( - - - - -) diff --git a/packages/playground/admin/lib/components/datagrid/filters/common.tsx b/packages/playground/admin/lib/components/datagrid/filters/common.tsx index fbf609f18..fa53c0980 100644 --- a/packages/playground/admin/lib/components/datagrid/filters/common.tsx +++ b/packages/playground/admin/lib/components/datagrid/filters/common.tsx @@ -1,10 +1,10 @@ import * as React from 'react' -import { ReactEventHandler, useCallback } from 'react' +import { useCallback } from 'react' import { useDataViewNullFilter } from '@contember/react-dataview' -import { DataViewFilterSelectItemUI } from '../ui' +import { DataGridFilterSelectItemUI } from '../ui' import { dict } from '../../../dict' -export const DataViewNullFilter = ({ name }: { +export const DataGridNullFilter = ({ name }: { name: string }) => { @@ -13,7 +13,7 @@ export const DataViewNullFilter = ({ name }: { const toggleIncludeNull = useCallback(() => setNullFilter('toggleInclude'), [setNullFilter]) return <> - {dict.datagrid.na} - + } + diff --git a/packages/playground/admin/lib/components/datagrid/filters/date.tsx b/packages/playground/admin/lib/components/datagrid/filters/date.tsx index f2d52e2d4..733357319 100644 --- a/packages/playground/admin/lib/components/datagrid/filters/date.tsx +++ b/packages/playground/admin/lib/components/datagrid/filters/date.tsx @@ -1,21 +1,34 @@ import * as React from 'react' import { ReactNode } from 'react' -import { - DataViewDateFilterInput, - DataViewDateFilterResetTrigger, - DataViewNullFilterTrigger, - DateRangeFilterArtifacts, - useDataViewFilter, -} from '@contember/react-dataview' -import { Component } from '@contember/interface' +import { createDateFilter, DataViewDateFilterInput, DataViewDateFilterResetTrigger, DataViewFilter, DataViewNullFilterTrigger, DateRangeFilterArtifacts, useDataViewFilter } from '@contember/react-dataview' +import { Component, SugaredRelativeSingleField } from '@contember/interface' import { Popover, PopoverContent, PopoverTrigger } from '../../ui/popover' -import { DataViewActiveFilterUI, DataViewFilterSelectTriggerUI, DataViewSingleFilterUI } from '../ui' -import { DataViewNullFilter } from './common' +import { DataGridActiveFilterUI, DataGridFilterSelectTriggerUI, DataGridSingleFilterUI } from '../ui' import { Input } from '../../ui/input' import { formatDate } from '../../../utils/formatting' import { dict } from '../../../dict' +import { DataGridNullFilter } from './common' +import { getFilterName } from './utils' -const DataViewDateFilterRange = ({ name }: { name: string }) => { +export type DataGridDateFilterProps = { + field: SugaredRelativeSingleField['field'] + name?: string + label: ReactNode +} + +export const DataGridDateFilter = Component(({ name: nameIn, field, label }: DataGridDateFilterProps) => { + const name = getFilterName(nameIn, field) + return ( + + + + + ) +}, ({ name, field }) => { + return +}) + +const DataGridDateFilterRange = ({ name }: { name: string }) => { const [artifact] = useDataViewFilter(name) if (!artifact) { return null @@ -33,34 +46,36 @@ const DataViewDateFilterRange = ({ name }: { name: string }) => { return `≤ ${endFormatted}` } return undefined + } -const DataViewDateFilterList = ({ name }: { + +const DataGridDateFilterList = ({ name }: { name: string }) => ( <> - - - + + + - + {dict.datagrid.na} - + ) -const DataViewDateFilterSelect = ({ name, label }: { +const DataGridDateFilterSelect = ({ name, label }: { name: string label?: ReactNode }) => ( - {label} + {label}
@@ -75,21 +90,8 @@ const DataViewDateFilterSelect = ({ name, label }: {
- +
) - - -export const DefaultDataViewDateFilter = Component(({ name, label }: { - name: string - label: ReactNode -}) => { - return ( - - - - - ) -}, () => null) diff --git a/packages/playground/admin/lib/components/datagrid/filters/enum.tsx b/packages/playground/admin/lib/components/datagrid/filters/enum.tsx index b79987e16..2d04131c9 100644 --- a/packages/playground/admin/lib/components/datagrid/filters/enum.tsx +++ b/packages/playground/admin/lib/components/datagrid/filters/enum.tsx @@ -1,43 +1,46 @@ import * as React from 'react' -import { ReactEventHandler, ReactNode, useCallback } from 'react' +import { ReactNode, useCallback } from 'react' import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '../../ui/tooltip' import { Button } from '../../ui/button' -import { - DataViewEnumFilterTrigger, - DataViewNullFilterTrigger, - EnumFilterArtifacts, - UseDataViewEnumFilter, - useDataViewEnumFilterFactory, - useDataViewFilter, -} from '@contember/react-dataview' -import { Component } from '@contember/interface' +import { createEnumFilter, DataViewEnumFilterTrigger, DataViewFilter, DataViewNullFilterTrigger, UseDataViewEnumFilter, useDataViewEnumFilterFactory } from '@contember/react-dataview' +import { Component, SugaredRelativeSingleField } from '@contember/interface' import { Popover, PopoverContent, PopoverTrigger } from '../../ui/popover' -import { - DataViewActiveFilterUI, - DataViewExcludeActionButtonUI, - DataViewFilterActionButtonUI, - DataViewFilterSelectItemUI, - DataViewFilterSelectTriggerUI, - DataViewSingleFilterUI, -} from '../ui' -import { DataViewNullFilter } from './common' +import { DataGridActiveFilterUI, DataGridExcludeActionButtonUI, DataGridFilterActionButtonUI, DataGridFilterSelectItemUI, DataGridFilterSelectTriggerUI, DataGridSingleFilterUI } from '../ui' +import { DataGridNullFilter } from './common' import { dict } from '../../../dict' +import { getFilterName } from './utils' -export const DataViewEnumFieldTooltip = ({ filter, children, actions, value }: { filter: string, children: ReactNode, value: string, actions?: ReactNode }) => ( +export type DataGridEnumFilterProps = { + field: SugaredRelativeSingleField['field'] + name?: string + options: Record + label: ReactNode +} +export const DataGridEnumFilter = Component(({ name: nameIn, field, options, label }: DataGridEnumFilterProps) => { + const name = getFilterName(nameIn, field) + return ( + + + + + ) +}, ({ name, field }) => { + return +}) + +export const DataGridEnumFieldTooltip = ({ filter, children, actions, value }: { filter: string, children: ReactNode, value: string, actions?: ReactNode }) => ( - + {children}
- + - + {actions}
@@ -46,81 +49,68 @@ export const DataViewEnumFieldTooltip = ({ filter, children, actions, value }: {
) -const DataViewEnumFilterList = ({ name, options }: { + +const DataGridEnumFilterList = ({ name, options }: { name: string options: Record }) => ( <> {Object.entries(options).map(([value, label]) => ( - + {label} - + ))} - + {dict.datagrid.na} - + ) - - -const DataViewEnumFilterSelectItem = ({ value, children, filterFactory }: { +const DataGridEnumFilterSelectItem = ({ value, children, filterFactory }: { value: string children: ReactNode filterFactory: (value: string) => UseDataViewEnumFilter }) => { - const [current, setFilter] = filterFactory(value) + const [current, setFilter] = filterFactory(value) const include = useCallback(() => setFilter('toggleInclude'), [setFilter]) const exclude = useCallback(() => setFilter('toggleExclude'), [setFilter]) const isIncluded = current === 'include' - const isExcluded = current == 'exclude' + const isExcluded = current == 'exclude' return ( - + {children} - + ) -} -const DataViewEnumFilterSelect = ({ name, options, label }: { +} +const DataGridEnumFilterSelect = ({ name, options, label }: { name: string options: Record label?: ReactNode }) => { - const filterFactory = useDataViewEnumFilterFactory(name) + const filterFactory = useDataViewEnumFilterFactory(name) return ( - {label} + {label}
{Object.entries(options).map(([value, label]) => ( - + {label} - + ))} - +
) } - - -export const DefaultDataViewEnumFilter = Component(({ name, options, label }: { - name: string - options: Record - label: ReactNode -}) => ( - - - - -), () => null) diff --git a/packages/playground/admin/lib/components/datagrid/filters/number.tsx b/packages/playground/admin/lib/components/datagrid/filters/number.tsx index 5a63e4173..ea40abf82 100644 --- a/packages/playground/admin/lib/components/datagrid/filters/number.tsx +++ b/packages/playground/admin/lib/components/datagrid/filters/number.tsx @@ -1,20 +1,35 @@ import * as React from 'react' import { ReactNode } from 'react' -import { - DataViewNullFilterTrigger, - DataViewNumberFilterInput, - DataViewNumberFilterResetTrigger, - NumberRangeFilterArtifacts, - useDataViewFilter, -} from '@contember/react-dataview' +import { createNumberFilter, DataViewFilter, DataViewNullFilterTrigger, DataViewNumberFilterInput, DataViewNumberFilterResetTrigger, NumberRangeFilterArtifacts, useDataViewFilter } from '@contember/react-dataview' import { Popover, PopoverContent, PopoverTrigger } from '../../ui/popover' -import { DataViewActiveFilterUI, DataViewFilterSelectTriggerUI, DataViewSingleFilterUI } from '../ui' -import { DataViewNullFilter } from './common' +import { DataGridActiveFilterUI, DataGridFilterSelectTriggerUI, DataGridSingleFilterUI } from '../ui' +import { DataGridNullFilter } from './common' import { Input } from '../../ui/input' import { formatNumber } from '../../../utils/formatting' import { dict } from '../../../dict' +import { Component, SugaredRelativeSingleField } from '@contember/interface' +import { getFilterName } from './utils' -const DataViewNumberFilterRange = ({ name }: { +export type DataGridNumberFilterProps = { + field: SugaredRelativeSingleField['field'] + name?: string + label: ReactNode +} + +export const DataGridNumberFilter = Component(({ name: nameIn, field, label }: DataGridNumberFilterProps) => { + const name = getFilterName(nameIn, field) + return ( + + + + + ) +}, ({ name, field }) => { + return +}) + + +const DataGridNumberFilterRange = ({ name }: { name: string }) => { const [artifact] = useDataViewFilter(name) @@ -34,32 +49,32 @@ const DataViewNumberFilterRange = ({ name }: { } -export const DataViewNumberFilterList = ({ name }: { +export const DataGridNumberFilterList = ({ name }: { name: string }) => ( <> - - - + + + - + {dict.datagrid.na} - + ) -export const DataViewNumberFilterSelect = ({ name, label }: { +export const DataGridNumberFilterSelect = ({ name, label }: { name: string label?: ReactNode }) => ( - {label} + {label}
@@ -75,19 +90,8 @@ export const DataViewNumberFilterSelect = ({ name, label }: {
- +
) - - -export const DefaultDataViewNumberFilter = ({ name, label }: { - name: string - label: ReactNode -}) => ( - - - - -) diff --git a/packages/playground/admin/lib/components/datagrid/filters/relation.tsx b/packages/playground/admin/lib/components/datagrid/filters/relation.tsx index afaba6791..2c7c790ed 100644 --- a/packages/playground/admin/lib/components/datagrid/filters/relation.tsx +++ b/packages/playground/admin/lib/components/datagrid/filters/relation.tsx @@ -1,30 +1,90 @@ import * as React from 'react' import { forwardRef, ReactNode, useCallback } from 'react' import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '../../ui/tooltip' -import { Button } from '../../ui/button' -import { DataView, DataViewNullFilterTrigger, DataViewRelationFilterList, DataViewRelationFilterTrigger, useDataViewRelationFilterFactory, UseDataViewRelationFilterResult } from '@contember/react-dataview' -import { Component, EntityId, SugarableQualifiedEntityList, SugaredQualifiedEntityList, useEntity } from '@contember/interface' +import { + createHasManyFilter, + createHasOneFilter, createUnionTextFilter, + DataView, + DataViewFilter, + DataViewNullFilterTrigger, + DataViewRelationFilterList, + DataViewRelationFilterTrigger, + useDataViewRelationFilterFactory, + UseDataViewRelationFilterResult, +} from '@contember/react-dataview' +import { Component, EntityId, SugarableQualifiedEntityList, SugaredQualifiedEntityList, SugaredRelativeEntityList, SugaredRelativeSingleEntity, useEntity } from '@contember/interface' import { Popover, PopoverTrigger } from '../../ui/popover' -import { DataViewActiveFilterUI, DataViewExcludeActionButtonUI, DataViewFilterActionButtonUI, DataViewFilterSelectItemUI, DataViewFilterSelectTriggerUI, DataViewSingleFilterUI } from '../ui' -import { DataViewNullFilter } from './common' -import { createDefaultSelectFilter, SelectListInner, SelectPopoverContent } from '../../select' +import { DataGridActiveFilterUI, DataGridExcludeActionButtonUI, DataGridFilterActionButtonUI, DataGridFilterSelectItemUI, DataGridFilterSelectTriggerUI, DataGridSingleFilterUI } from '../ui' +import { DataGridNullFilter } from './common' +import { SelectDefaultFilter, SelectListInner, SelectPopoverContent } from '../../select' import { dict } from '../../../dict' +import { SelectFilterFieldProps } from '@contember/react-select' +import { getFilterName } from './utils' +import { PartialSome } from '@contember/utilities' -export const DataViewRelationFieldTooltip = ({ filter, children, actions }: { filter: string, children: ReactNode, actions?: ReactNode }) => ( + +type DataGridRelationFilterCommonProps = { + name: string + options: SugaredQualifiedEntityList['entities'] + children: ReactNode + label: ReactNode + filterField?: string +} + +export type DataGridHasOneFilterProps = + & PartialSome + & { + field: SugaredRelativeSingleEntity['field'] + } + +export const DataGridHasOneFilter = Component(({ name: nameIn, field, ...props }: DataGridHasOneFilterProps) => { + const name = getFilterName(nameIn, field) + return +}, ({ name, field }) => { + return +}) + + +export type DataGridHasManyFilterProps = + & PartialSome + & { + field: SugaredRelativeEntityList['field'] + } + +export const DataGridHasManyFilter = Component(({ name: nameIn, field, ...props }: DataGridHasManyFilterProps) => { + const name = getFilterName(nameIn, field) + return +}, ({ name, field }) => { + return +}) + +const DataGridRelationFilterInner = Component(({ name, options, children, label, filterField }: DataGridRelationFilterCommonProps) => { + return ( + + + {children} + + + {children} + + + ) +}, () => null) + + +export const DataGridRelationFieldTooltip = ({ filter, children, actions }: { filter: string, children: ReactNode, actions?: ReactNode }) => ( - + {children}
- + - + {actions}
@@ -33,7 +93,7 @@ export const DataViewRelationFieldTooltip = ({ filter, children, actions }: { fi
) -const DataViewRelationFilteredItemsList = ({ name, children, options }: { +const DataGridRelationFilteredItemsList = ({ name, children, options }: { name: string options: SugaredQualifiedEntityList['entities'] children: ReactNode @@ -41,20 +101,20 @@ const DataViewRelationFilteredItemsList = ({ name, children, options }: { <> - + {children} - + - + {dict.datagrid.na} - + ) -const DataViewRelationFilterSelectItem = forwardRef UseDataViewRelationFilterResult }>(({ children, filterFactory, ...props }, ref) => { @@ -68,64 +128,44 @@ const DataViewRelationFilterSelectItem = forwardRef + {children} - + ) }) -const DataViewRelationFilterSelect = ({ name, children, options, filterField, label }: { +const DataGridRelationFilterSelect = ({ name, children, options, filterField, label }: SelectFilterFieldProps & { name: string - filterField?: string options: string | SugarableQualifiedEntityList children: ReactNode label?: ReactNode }) => { - const filter = filterField ? createDefaultSelectFilter(filterField) : { filterTypes: undefined, filterToolbar: undefined } + const filter = filterField ? { query: createUnionTextFilter(Array.isArray(filterField) ? filterField : [filterField]) } : undefined let filterFactory = useDataViewRelationFilterFactory(name) return ( - + {label} - + - { - const [, set] = filterFactory(it.id) - set('toggleInclude') - }}> - - + { + const [, set] = filterFactory(it.id) + set('toggleInclude') + }} filteringStateStorage="null" sortingStateStorage="null" currentPageStateStorage="null"> + }> + {children} - +
- +
) } - -export const DefaultDataViewRelationFilter = Component(({ name, options, children, label, filterField }: { - name: string - options: SugaredQualifiedEntityList['entities'] - children: ReactNode - label: ReactNode - filterField?: string -}) => { - return ( - - - {children} - - - {children} - - - ) -}, () => null) diff --git a/packages/playground/admin/lib/components/datagrid/filters/text.tsx b/packages/playground/admin/lib/components/datagrid/filters/text.tsx index da77ed370..9c5634555 100644 --- a/packages/playground/admin/lib/components/datagrid/filters/text.tsx +++ b/packages/playground/admin/lib/components/datagrid/filters/text.tsx @@ -3,62 +3,100 @@ import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigge import { Button } from '../../ui/button' import { MoreHorizontalIcon, XIcon } from 'lucide-react' import * as React from 'react' -import { DataViewNullFilterTrigger, DataViewTextFilterInput, DataViewTextFilterMatchModeLabel, DataViewTextFilterMatchModeTrigger, DataViewTextFilterResetTrigger, TextFilterArtifactsMatchMode } from '@contember/react-dataview' +import { + createTextFilter, + createUnionTextFilter, + DataViewFilter, + DataViewNullFilterTrigger, + DataViewTextFilterInput, + DataViewTextFilterMatchModeLabel, + DataViewTextFilterMatchModeTrigger, + DataViewTextFilterResetTrigger, + TextFilterArtifactsMatchMode, +} from '@contember/react-dataview' import { Popover, PopoverContent, PopoverTrigger } from '../../ui/popover' -import { DataViewActiveFilterUI } from '../ui' -import { DataViewNullFilter } from './common' +import { DataGridActiveFilterUI } from '../ui' +import { DataGridNullFilter } from './common' import { dict } from '../../../../lib/dict' +import { Component, SugaredRelativeSingleField } from '@contember/interface' +import { getFilterName } from './utils' -export const DataViewTextFilter = ({ name, label }: { +export type DataGridTextFilterProps = { + field: SugaredRelativeSingleField['field'] + name?: string + label?: React.ReactNode +} + +export const DataGridTextFilter = Component(({ name: nameIn, field, label }: DataGridTextFilterProps) => { + const name = getFilterName(nameIn, field) + return +}, ({ name, field }) => { + return +}) +export type DataGridUnionTextFilterProps = { + fields: SugaredRelativeSingleField['field'][] name: string - label: React.ReactNode -}) => ( - <> - - - - - - - {Object.entries(dict.datagrid.textMatchMode).map(([mode, label]) => ( - - - {label} - - - ))} - - + label?: React.ReactNode +} - - - +export const DataGridUnionTextFilter = Component(({ name, label }: DataGridUnionTextFilterProps) => { + return +}, ({ name, fields }) => { + return +}) -
- - - {dict.datagrid.na} - - - - - - - - - - - - - -
-
- -) + + + + + + + + + + + + ) +} diff --git a/packages/playground/admin/lib/components/datagrid/filters/utils.ts b/packages/playground/admin/lib/components/datagrid/filters/utils.ts new file mode 100644 index 000000000..768e5051d --- /dev/null +++ b/packages/playground/admin/lib/components/datagrid/filters/utils.ts @@ -0,0 +1,7 @@ +export const getFilterName = (name: string | undefined, field: string | T): string => { + const resolvedName = name ?? field + if (typeof resolvedName === 'string') { + return resolvedName + } + throw new Error('Please provide a name for the filter') +} diff --git a/packages/playground/admin/lib/components/datagrid/grid.tsx b/packages/playground/admin/lib/components/datagrid/grid.tsx index 33d6b557c..cd4b25bc3 100644 --- a/packages/playground/admin/lib/components/datagrid/grid.tsx +++ b/packages/playground/admin/lib/components/datagrid/grid.tsx @@ -1,106 +1,134 @@ -import { - DataView, - DataViewEachRow, - DataViewEmpty, - DataViewFilterHandler, - DataViewHasSelection, - DataViewLoaderState, - DataViewNonEmpty, - DataViewProps, - DataViewSelectionTrigger, -} from '@contember/react-dataview' -import { EyeIcon, EyeOffIcon } from 'lucide-react' +import { DataView, DataViewEachRow, DataViewEmpty, DataViewFilterHandler, DataViewHasSelection, DataViewLoaderState, DataViewNonEmpty, DataViewProps, useDataViewFilteringState } from '@contember/react-dataview' +import { FilterIcon, SettingsIcon } from 'lucide-react' import * as React from 'react' import { Fragment, ReactNode, useMemo } from 'react' -import { dict } from '../../dict' import { Button } from '../ui/button' -import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger } from '../ui/dropdown' -import { DataViewNoResults } from './empty' -import { DataViewLayoutSwitcher } from './layout-switcher' -import { DataViewInitialLoader, DataViewLoaderOverlay } from './loader' -import { DataTablePagination } from './pagination' -import { DataViewTable, DataViewTableColumn } from './table' - -export type DataViewColumn = - & DataViewTableColumn +import { DataGridNoResults } from './empty' +import { DataGridLayoutSwitcher } from './layout-switcher' +import { DataGridInitialLoader, DataGridOverlayLoader } from './loader' +import { DataGridPagination, DataGridPerPageSelector } from './pagination' +import { DataGridTable, DataGridTableColumn } from './table' +import { DataGridTextFilter, DataGridUnionTextFilter } from './filters' +import { DataGridToolbarUI } from './ui' +import { DataGridAutoExport } from './export' +import { Popover, PopoverContent, PopoverTrigger } from '../ui/popover' +import { DataGridToolbarVisibleFields } from './columns-hiding' +import { Component } from '@contember/interface' +import { dataAttribute } from '@contember/utilities' +import { cn } from '../../utils/cn' + +export type DataGridColumn = + & DataGridTableColumn & { + type: 'text' | 'hasOne' | 'hasMany' | 'boolean' | 'number' | 'enum' | 'date' + field: string filterName?: string - filterHandler?: DataViewFilterHandler filterToolbar?: ReactNode } -export type DefaultDataGridProps = +export type DataGridProps = & Omit & { - columns: DataViewColumn[] + searchFields?: string[] + columns: DataGridColumn[] tile?: ReactNode firstColumnActions?: ReactNode lastColumnActions?: ReactNode + toolbarButtons?: ReactNode } -const DataGridToolbarFilters = ({ columns }: { columns: DataViewColumn[] }) => { +const DataGridToolbarFilters = Component(({ columns, alwaysShow }: { columns: DataGridColumn[], alwaysShow: boolean }) => { return <> {columns - .filter(it => it.filterToolbar && it.filterName) - .map(column => {column.filterToolbar}) - } + .map(column => { + if (!column.filterName || !column.filterToolbar) { + return null + } + return ( + + {column.filterToolbar} + + ) + })} -} +}) -const DataGridToolbarColumns = ({ columns }: { columns: DataViewColumn[] }) => { - return - - - - - {columns.map(column => ( - column.hidingName && !it}> - e.preventDefault()} - className={'gap-1 group text-gray-500 data-[current]:text-black'}> - - - ))} - - +const DataGridFilterMobileHiding = ({ name, alwaysShow, children }: { name: string, alwaysShow: boolean, children: ReactNode }) => { + const activeFilter = useDataViewFilteringState().artifact + const isActive = !!activeFilter[name] + return ( +
+ {children} +
+ ) } -export const DefaultDataGrid = ({ columns, tile, lastColumnActions, firstColumnActions, ...props }: DefaultDataGridProps) => { - const filterTypes = useMemo(() => { - return Object.fromEntries(columns - .filter(it => it.filterHandler) - .map(it => [it.filterName, it.filterHandler]), - ) as Record> - }, [columns]) +export const DataGrid = ({ columns, tile, lastColumnActions, firstColumnActions, searchFields, toolbarButtons, ...props }: DataGridProps) => { + const searchFieldsResolved = useMemo(() => { + return searchFields ?? columns.filter(it => it.type === 'text').map(it => it.field) + }, [columns, searchFields]) + + const [showFilters, setShowFilters] = React.useState(false) return ( - - -
- {tile && } - + + +
+ + + + + + +
+ {tile && } + it.hidingName).map(it => ({ header: it.header, name: it.hidingName as string }))} /> + +
+ +
+
+ + {toolbarButtons}
- }> + +
+ {searchFieldsResolved.length && ( + + + + )} + +
+
+ + + - +
@@ -109,26 +137,24 @@ export const DefaultDataGrid = ({ columns, tile, lastColumnActions, firstColumnA
- +
+ + ) } export interface DataViewBodyProps { - toolbar: ReactNode children: ReactNode } -export const DataViewBody = ({ children, toolbar }: DataViewBodyProps) => ( -
-
- {toolbar} -
+export const DataGridLoader = ({ children }: DataViewBodyProps) => ( + <>
- + @@ -136,15 +162,14 @@ export const DataViewBody = ({ children, toolbar }: DataViewBodyProps) => ( - +
- + - -
+ ) diff --git a/packages/playground/admin/lib/components/datagrid/layout-switcher.tsx b/packages/playground/admin/lib/components/datagrid/layout-switcher.tsx index 785d1426a..3fd5431fc 100644 --- a/packages/playground/admin/lib/components/datagrid/layout-switcher.tsx +++ b/packages/playground/admin/lib/components/datagrid/layout-switcher.tsx @@ -4,23 +4,23 @@ import { LayoutGridIcon, SheetIcon } from 'lucide-react' import * as React from 'react' import { dict } from '../../dict' -export const DataViewLayoutSwitcher = () => <> -
+export const DataGridLayoutSwitcher = () =>
+

{dict.datagrid.layout}

+
-
- +
diff --git a/packages/playground/admin/lib/components/datagrid/loader.tsx b/packages/playground/admin/lib/components/datagrid/loader.tsx index 632a4cba1..6af5c6e9d 100644 --- a/packages/playground/admin/lib/components/datagrid/loader.tsx +++ b/packages/playground/admin/lib/components/datagrid/loader.tsx @@ -1,10 +1,10 @@ import * as React from 'react' import { Loader } from '../ui/loader' -export const DataViewLoaderOverlay = () => ( +export const DataGridOverlayLoader = () => ( ) -export const DataViewInitialLoader = () => ( +export const DataGridInitialLoader = () => ( ) diff --git a/packages/playground/admin/lib/components/datagrid/pagination.tsx b/packages/playground/admin/lib/components/datagrid/pagination.tsx index e283e6c88..a825aa5ea 100644 --- a/packages/playground/admin/lib/components/datagrid/pagination.tsx +++ b/packages/playground/admin/lib/components/datagrid/pagination.tsx @@ -7,28 +7,32 @@ import { DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger } from '../u import { dictFormat } from '../../utils/dictFormat' import { dict } from '../../dict' -export const DataTablePagination = () => ( -
+export const DataGridPagination = () => ( +
<>
-
- {dictFormat(it.pagesCount !== undefined ? dict.datagrid.pageInfo : dict.datagrid.pageInfoShort, { - page: (it.pageIndex + 1).toString(), - pagesCount: it.pagesCount?.toString() ?? '', - })} -
-
- ({it.totalCount === undefined ? +
+ {it.totalCount === undefined ? : dictFormat(dict.datagrid.pageRowsCount, { totalCount: it.totalCount.toString(), }) - }) + }
} />
+ <> +
+
+ {dictFormat(it.pagesCount !== undefined ? dict.datagrid.pageInfo : dict.datagrid.pageInfoShort, { + page: (it.pageIndex + 1).toString(), + pagesCount: it.pagesCount?.toString() ?? '', + })} +
+
+ } />
-
-

{dict.datagrid.paginationRowsPerPage}

- - -
+) - - - - - {[5, 10, 20, 30, 40, 50].map(pageSize => ( - - - {pageSize} - - - ))} - - -
+ +export const DataGridPerPageSelector = () => ( +
+

{dict.datagrid.paginationRowsPerPage}

+ + + + + + {[5, 10, 20, 30, 40, 50].map(pageSize => ( + + + {pageSize} + + + ))} + +
) diff --git a/packages/playground/admin/lib/components/datagrid/table.tsx b/packages/playground/admin/lib/components/datagrid/table.tsx index 4d719a86c..dc4934e23 100644 --- a/packages/playground/admin/lib/components/datagrid/table.tsx +++ b/packages/playground/admin/lib/components/datagrid/table.tsx @@ -1,11 +1,11 @@ import { Component } from '@contember/interface' import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '../ui/table' -import { DataViewColumnHeader } from './column-header' +import { DataGridColumnHeader } from './column-header' import * as React from 'react' import { ReactNode } from 'react' import { DataViewEachRow, DataViewHasSelection } from '@contember/react-dataview' -export type DataViewTableColumn = { +export type DataGridTableColumn = { header: ReactNode cell: ReactNode hidingName?: string @@ -15,10 +15,10 @@ export type DataViewTableColumn = { export interface DataViewTableProps { firstColumnActions?: ReactNode lastColumnActions?: ReactNode - columns: DataViewTableColumn[] + columns: DataGridTableColumn[] } -export const DataViewTable = Component(({ columns, firstColumnActions, lastColumnActions }: DataViewTableProps) => { +export const DataGridTable = Component(({ columns, firstColumnActions, lastColumnActions }: DataViewTableProps) => { return (
@@ -30,9 +30,9 @@ export const DataViewTable = Component(({ columns, firstColumnActions, lastColum {Object.entries(columns).map(([key, { header, hidingName, sortingField }]) => ( - + {header} - + ))} diff --git a/packages/playground/admin/lib/components/datagrid/ui.tsx b/packages/playground/admin/lib/components/datagrid/ui.tsx index 27b6fa40f..b3f3380fb 100644 --- a/packages/playground/admin/lib/components/datagrid/ui.tsx +++ b/packages/playground/admin/lib/components/datagrid/ui.tsx @@ -1,14 +1,19 @@ import * as React from 'react' -import { forwardRef, ReactEventHandler, ReactNode, SyntheticEvent, useCallback } from 'react' +import { forwardRef, ReactEventHandler, ReactNode, useCallback } from 'react' import { CheckSquareIcon, FilterIcon, FilterXIcon, PlusIcon, SquareIcon, XIcon } from 'lucide-react' import { Button } from '../ui/button' import { cn } from '../../utils/cn' import { dict } from '../../dict' +import { uic } from '../../utils/uic' + +export const DataGridTooltipLabel = uic('span', { + baseClass: 'cursor-pointer border-dashed border-b border-b-gray-400 hover:border-gray-800', +}) /** * Button in a tooltip that triggers the filter action */ -export const DataViewFilterActionButtonUI = forwardRef((props: {}, ref) => { +export const DataGridFilterActionButtonUI = forwardRef((props: {}, ref) => { return ( - - + {hasRightSidebar ? -
- + + setRightSidebarVisibility('hidden')}> + +
-
- : null} + + : null + } - + -
+
-
- - + + + ) } LayoutComponent.displayName = 'Layout' diff --git a/packages/playground/admin/lib/components/select/filter.tsx b/packages/playground/admin/lib/components/select/filter.tsx index 563266319..922c9584c 100644 --- a/packages/playground/admin/lib/components/select/filter.tsx +++ b/packages/playground/admin/lib/components/select/filter.tsx @@ -1,25 +1,15 @@ import * as React from 'react' -import { ReactNode } from 'react' import { Input } from '../ui/input' -import { createTextFilter, DataViewFilterHandlerRegistry, DataViewTextFilterInput } from '@contember/react-dataview' -import { dict } from '../../../lib/dict' +import { DataViewHasFilterType, DataViewTextFilterInput } from '@contember/react-dataview' +import { dict } from '../../dict' -export const createDefaultSelectFilter = (filterField?: string): { - filterTypes?: DataViewFilterHandlerRegistry - filterToolbar?: ReactNode -} => { - if (!filterField) { - return { - filterTypes: undefined, - filterToolbar: undefined, - } - } - return { - filterTypes: { query: createTextFilter(filterField) }, - filterToolbar: ( + +export const SelectDefaultFilter = () => { + return ( + - + - ), - } + + ) } diff --git a/packages/playground/admin/lib/components/select/list.tsx b/packages/playground/admin/lib/components/select/list.tsx index ca05cf1cd..11d57b0fa 100644 --- a/packages/playground/admin/lib/components/select/list.tsx +++ b/packages/playground/admin/lib/components/select/list.tsx @@ -1,7 +1,7 @@ import * as React from 'react' import { ReactNode } from 'react' import { ScrollArea } from '../ui/scroll-area' -import { DataViewLoaderOverlay } from '../datagrid' +import { DataGridOverlayLoader } from '../datagrid' import { SelectPagination } from './pagination' import { Loader } from '../ui/loader' import { DataViewEachRow, DataViewHighlightRow, DataViewKeyboardEventHandler, DataViewLoaderState } from '@contember/react-dataview' @@ -22,7 +22,7 @@ export const SelectListInner = ({ children, filterToolbar }: SelectListProps) =>
- + diff --git a/packages/playground/admin/lib/components/select/multi-select.tsx b/packages/playground/admin/lib/components/select/multi-select.tsx index 4d4187293..b1650f1b8 100644 --- a/packages/playground/admin/lib/components/select/multi-select.tsx +++ b/packages/playground/admin/lib/components/select/multi-select.tsx @@ -1,28 +1,27 @@ import * as React from 'react' import { ReactNode } from 'react' import { MultiSelectItemContentUI, MultiSelectItemRemoveButtonUI, MultiSelectItemUI, SelectCreateNewTrigger, SelectDefaultPlaceholderUI, SelectInputActionsUI, SelectInputUI, SelectListItemUI, SelectPopoverContent } from './ui' -import { ChevronDownIcon, PlusIcon } from 'lucide-react' +import { ChevronDownIcon } from 'lucide-react' import { Popover, PopoverTrigger } from '../ui/popover' import { Component, SugaredQualifiedEntityList, SugaredRelativeEntityList } from '@contember/interface' -import { createDefaultSelectFilter } from './filter' +import { SelectDefaultFilter } from './filter' import { SelectListInner } from './list' -import { MultiSelect, SelectDataView, SelectEachValue, SelectItemTrigger, SelectOption, SelectPlaceholder } from '@contember/react-select' +import { MultiSelect, SelectDataView, SelectEachValue, SelectFilterFieldProps, SelectItemTrigger, SelectOption, SelectPlaceholder } from '@contember/react-select' import { CreateEntityDialog } from './create-new' -import { Button } from '../ui/button' -export interface MultiSelectInputProps { - field: SugaredRelativeEntityList['field'] - options: SugaredQualifiedEntityList['entities'] - children: ReactNode - filterField?: string - placeholder?: ReactNode - createNewForm?: ReactNode +export type MultiSelectInputProps = + & SelectFilterFieldProps + & { + field: SugaredRelativeEntityList['field'] + options?: SugaredQualifiedEntityList['entities'] + children: ReactNode + placeholder?: ReactNode + createNewForm?: ReactNode } export const MultiSelectInput = Component(({ field, filterField, options, children, placeholder, createNewForm }) => { - const filter = createDefaultSelectFilter(filterField) return ( - +
@@ -48,8 +47,8 @@ export const MultiSelectInput = Component(({ field, filte - - + + }> @@ -68,5 +67,5 @@ export const MultiSelectInput = Component(({ field, filte )}
-) + ) }) diff --git a/packages/playground/admin/lib/components/select/select.tsx b/packages/playground/admin/lib/components/select/select.tsx index 289653b18..896c0eadd 100644 --- a/packages/playground/admin/lib/components/select/select.tsx +++ b/packages/playground/admin/lib/components/select/select.tsx @@ -7,26 +7,26 @@ import { SugaredRelativeSingleEntity } from '@contember/react-binding' import { ChevronDownIcon, XIcon } from 'lucide-react' import { SelectCreateNewTrigger, SelectDefaultPlaceholderUI, SelectInputActionsUI, SelectInputUI, SelectListItemUI, SelectPopoverContent } from './ui' import { SelectListInner } from './list' -import { createDefaultSelectFilter } from './filter' -import { Select, SelectDataView, SelectEachValue, SelectItemTrigger, SelectOption, SelectPlaceholder } from '@contember/react-select' +import { Select, SelectDataView, SelectEachValue, SelectFilterFieldProps, SelectItemTrigger, SelectOption, SelectPlaceholder } from '@contember/react-select' import { CreateEntityDialog } from './create-new' +import { SelectDefaultFilter } from './filter' -export interface SelectInputProps { - field: SugaredRelativeSingleEntity['field'] - children: ReactNode - options: SugaredQualifiedEntityList['entities'] - filterField?: string - placeholder?: ReactNode - createNewForm?: ReactNode -} +export type SelectInputProps = + & SelectFilterFieldProps + & { + field: SugaredRelativeSingleEntity['field'] + children: ReactNode + options?: SugaredQualifiedEntityList['entities'] + placeholder?: ReactNode + createNewForm?: ReactNode + } -export const SelectInput = Component(({ field, filterField, options, children, placeholder, createNewForm }, env) => { - const filter = createDefaultSelectFilter(filterField) +export const SelectInput = Component(({ field, filterField, options, children, placeholder, createNewForm }) => { const [open, setOpen] = React.useState(false) return ( - setOpen(false)} options={options} filterField={filterField}>
@@ -52,8 +52,8 @@ export const SelectInput = Component(({ field, filterField, op - - + + }> diff --git a/packages/playground/admin/lib/components/select/sortable-select.tsx b/packages/playground/admin/lib/components/select/sortable-select.tsx index 930a4d9fc..cd9102b07 100644 --- a/packages/playground/admin/lib/components/select/sortable-select.tsx +++ b/packages/playground/admin/lib/components/select/sortable-select.tsx @@ -1,4 +1,4 @@ -import { cn } from '../../../lib/utils/cn' +import { cn } from '../../utils/cn' import { DropIndicator } from '../ui/sortable' import * as React from 'react' import { ReactNode } from 'react' @@ -14,15 +14,14 @@ import { SelectListItemUI, SelectPopoverContent, } from './ui' -import { createDefaultSelectFilter } from './filter' import { Popover, PopoverTrigger } from '../ui/popover' -import { ChevronDownIcon, PlusIcon } from 'lucide-react' +import { ChevronDownIcon } from 'lucide-react' import { SelectListInner } from './list' import { RepeaterSortable, RepeaterSortableDragOverlay, RepeaterSortableDropIndicator, RepeaterSortableEachItem, RepeaterSortableItemActivator, RepeaterSortableItemNode } from '@contember/react-repeater-dnd-kit' import { Component, HasOne, SugaredQualifiedEntityList, SugaredRelativeEntityList, SugaredRelativeSingleEntity, SugaredRelativeSingleField } from '@contember/interface' -import { SelectDataView, SelectItemTrigger, SelectOption, SelectPlaceholder, SortableMultiSelect } from '@contember/react-select' +import { SelectDataView, SelectFilterFieldProps, SelectItemTrigger, SelectOption, SelectPlaceholder, SortableMultiSelect } from '@contember/react-select' import { CreateEntityDialog } from './create-new' -import { Button } from '../ui/button' +import { SelectDefaultFilter } from './filter' const MultiSortableSelectDropIndicator = ({ position }: { position: 'before' | 'after' }) => (
@@ -32,21 +31,21 @@ const MultiSortableSelectDropIndicator = ({ position }: { position: 'before' | '
) -export interface SortableMultiSelectInputProps { - field: SugaredRelativeEntityList['field'] - sortableBy: SugaredRelativeSingleField['field'] - connectAt: SugaredRelativeSingleEntity['field'] - children: ReactNode - options: SugaredQualifiedEntityList['entities'] - filterField?: string - placeholder?: ReactNode - createNewForm?: ReactNode -} +export type SortableMultiSelectInputProps = + & SelectFilterFieldProps + & { + field: SugaredRelativeEntityList['field'] + sortableBy: SugaredRelativeSingleField['field'] + connectAt: SugaredRelativeSingleEntity['field'] + children: ReactNode + options?: SugaredQualifiedEntityList['entities'] + placeholder?: ReactNode + createNewForm?: ReactNode + } export const SortableMultiSelectInput = Component(({ field, filterField, options, children, sortableBy, connectAt, placeholder, createNewForm }) => { - const filter = createDefaultSelectFilter(filterField) return ( - +
@@ -94,8 +93,8 @@ export const SortableMultiSelectInput = Component - - + + }> diff --git a/packages/playground/admin/lib/components/ui/input.tsx b/packages/playground/admin/lib/components/ui/input.tsx index 122cea501..77aac7307 100644 --- a/packages/playground/admin/lib/components/ui/input.tsx +++ b/packages/playground/admin/lib/components/ui/input.tsx @@ -1,7 +1,7 @@ import { uic } from '../../../lib/utils/uic' export const Input = uic('input', { - baseClass: 'flex w-full border border-input bg-background m ring-offset-background file:border-0 file:bg-transparent file:text-sm file:font-medium placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50 data-[invalid]:border-destructive data-[invalid]:ring-destructive', + baseClass: 'flex w-full border border-input bg-background ring-offset-background file:border-0 file:bg-transparent file:text-sm file:font-medium placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50 data-[invalid]:border-destructive data-[invalid]:ring-destructive', variants: { inputSize: { default: 'h-10 rounded-md p-2 text-sm', @@ -14,6 +14,10 @@ export const Input = uic('input', { displayName: 'Input', }) +export const SelectInput = uic('select', { + baseClass: 'flex w-full h-10 rounded-md p-2 text-sm border border-input bg-background ring-offset-background file:border-0 file:bg-transparent file:text-sm file:font-medium placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50 data-[invalid]:border-destructive data-[invalid]:ring-destructive', + displayName: 'SelectInput', +}) export const InputLike = uic('div', { baseClass: ` diff --git a/packages/playground/admin/lib/components/ui/menu.tsx b/packages/playground/admin/lib/components/ui/menu.tsx index 58ae5f90d..0e62e38c7 100644 --- a/packages/playground/admin/lib/components/ui/menu.tsx +++ b/packages/playground/admin/lib/components/ui/menu.tsx @@ -1,21 +1,58 @@ import { Link, RoutingLinkTarget } from '@contember/react-routing' import { ReactNode } from 'react' import { uic } from '../../../lib/utils/uic' +import { RoleCondition, useProjectUserRoles } from '@contember/interface' +import { createContext } from '@contember/react-utils' + +export const MenuItemUI = uic('a', { + baseClass: 'flex justify-start py-2.5 px-2.5 w-full gap-2 rounded text-sm items-center transition-all duration-200', +}) + +export const MenuItemIconUI = uic('span', { + baseClass: 'w-4 text-gray-400 inline-flex items-center justify-center', +}) + +export const MenuSubMenuUI = uic('div', { + baseClass: 'ml-2', +}) export type MenuItem = { icon?: ReactNode label: ReactNode to?: RoutingLinkTarget + /** @deprecated use children instead */ subItems?: MenuItem[] lvl?: number + role?: RoleCondition + children?: ReactNode +} + +interface MenuContextValue { + level: number } -export interface MenuProps { +const [MenuContext, useMenuContext] = createContext('MenuContext', null) +export const Menu = ({ children }: { + children?: ReactNode +}) => { + return ( + +
+ {children} +
+
+ ) +} + +export interface MenuListProps { items: MenuItem[] lvl?: number } -export const MenuList = ({ items, lvl = 0 }: MenuProps) => { +/** + * @deprecated use Menu instead + */ +export const MenuList = ({ items, lvl = 0 }: MenuListProps) => { return (
{items.map((item, index) => ( @@ -25,33 +62,42 @@ export const MenuList = ({ items, lvl = 0 }: MenuProps) => { ) } -export const MenuItem = ({ icon, label, to, subItems, lvl = 0 }: MenuItem) => { +export const MenuItem = ({ icon, label, to, subItems, lvl, role, children }: MenuItem) => { + const projectRoles = useProjectUserRoles() + const menu = useMenuContext() + lvl ??= menu?.level ?? 0 + if (role && !(typeof role === 'string' ? projectRoles.has(role) : role(projectRoles))) { + return null + } + return (
{to ? ( - - {icon} + + {icon} {label} - - + ) : ( - - {icon} + + {icon} {label} - - + )} {subItems && ( -
+ -
+ + )} + + {children && ( + + + {children} + + )}
) } - -export const MenuLink = uic('a', { - baseClass: 'flex justify-start py-2.5 px-2.5 w-full gap-1 rounded text-sm items-center transition-all duration-200', -}) diff --git a/packages/playground/admin/lib/components/ui/table.tsx b/packages/playground/admin/lib/components/ui/table.tsx index b8f7d4d5c..da4ee1444 100644 --- a/packages/playground/admin/lib/components/ui/table.tsx +++ b/packages/playground/admin/lib/components/ui/table.tsx @@ -31,12 +31,12 @@ export const TableRow = uic('tr', { }) export const TableHead = uic('th', { - baseClass: 'h-12 px-4 text-left align-middle font-medium text-muted-foreground [&:has([role=checkbox])]:pr-0', + baseClass: 'px-4 py-3 text-left align-middle font-medium text-muted-foreground [&:has([role=checkbox])]:pr-0', displayName: 'TableHead', }) export const TableCell = uic('td', { - baseClass: 'p-4 align-middle [&:has([role=checkbox])]:pr-0', + baseClass: 'px-4 py-3 align-middle [&:has([role=checkbox])]:pr-0', displayName: 'TableCell', }) diff --git a/packages/playground/admin/lib/dict.ts b/packages/playground/admin/lib/dict.ts index e28c73d7b..6c8876f62 100644 --- a/packages/playground/admin/lib/dict.ts +++ b/packages/playground/admin/lib/dict.ts @@ -38,13 +38,14 @@ export const dict = { 'endsWith': 'ends with', 'doesNotMatch': 'does not contain', } satisfies Record, - columns: 'Columns', + visibleFields: 'Fields', columnAsc: 'asc', columnDesc: 'desc', columnHide: 'Hide', empty: 'No results.', - showGrid: 'Show grid', - showTable: 'Show table', + layout: 'Layout', + showGrid: 'Grid', + showTable: 'Table', paginationFirstPage: 'First page', paginationPreviousPage: 'Previous page', paginationNextPage: 'Next page', diff --git a/packages/playground/api/client/entities.ts b/packages/playground/api/client/entities.ts index 207db9a87..9b4a2ed8a 100644 --- a/packages/playground/api/client/entities.ts +++ b/packages/playground/api/client/entities.ts @@ -2,6 +2,8 @@ import type { BoardTaskStatus } from './enums' import type { GridArticleState } from './enums' import type { InputUnique } from './enums' import type { SelectUnique } from './enums' +import type { UploadMediaType } from './enums' +import type { UploadOne } from './enums' import type { InputRootEnumValue } from './enums' export type JSONPrimitive = string | number | boolean | null @@ -9,11 +11,11 @@ export type JSONValue = JSONPrimitive | JSONObject | JSONArray export type JSONObject = { readonly [K in string]?: JSONValue } export type JSONArray = readonly JSONValue[] -export type BoardTag = { +export type BoardTag = { name: 'BoardTag' unique: - | { id: string } - | { slug: string } + | Omit<{ id: string}, OverRelation> + | Omit<{ slug: string}, OverRelation> columns: { id: string name: string @@ -27,10 +29,10 @@ export type BoardTag = { hasManyBy: { } } -export type BoardTask = { +export type BoardTask = { name: 'BoardTask' unique: - | { id: string } + | Omit<{ id: string}, OverRelation> columns: { id: string title: string @@ -47,11 +49,11 @@ export type BoardTask = { hasManyBy: { } } -export type BoardUser = { +export type BoardUser = { name: 'BoardUser' unique: - | { id: string } - | { username: string } + | Omit<{ id: string}, OverRelation> + | Omit<{ username: string}, OverRelation> columns: { id: string name: string @@ -65,11 +67,12 @@ export type BoardUser = { hasManyBy: { } } -export type GridArticle = { +export type GridArticle = { name: 'GridArticle' unique: - | { id: string } - | { slug: string } + | Omit<{ id: string}, OverRelation> + | Omit<{ slug: string}, OverRelation> + | Omit<{ comments: GridArticleComment['unique']}, OverRelation> columns: { id: string title: string | null @@ -86,15 +89,34 @@ export type GridArticle = { } hasMany: { tags: GridTag + comments: GridArticleComment<'article'> } hasManyBy: { } } -export type GridAuthor = { +export type GridArticleComment = { + name: 'GridArticleComment' + unique: + | Omit<{ id: string}, OverRelation> + columns: { + id: string + content: string | null + createdAt: string | null + } + hasOne: { + article: GridArticle + author: GridAuthor + } + hasMany: { + } + hasManyBy: { + } +} +export type GridAuthor = { name: 'GridAuthor' unique: - | { id: string } - | { slug: string } + | Omit<{ id: string}, OverRelation> + | Omit<{ slug: string}, OverRelation> columns: { id: string name: string @@ -107,11 +129,11 @@ export type GridAuthor = { hasManyBy: { } } -export type GridCategory = { +export type GridCategory = { name: 'GridCategory' unique: - | { id: string } - | { slug: string } + | Omit<{ id: string}, OverRelation> + | Omit<{ slug: string}, OverRelation> columns: { id: string name: string @@ -124,11 +146,11 @@ export type GridCategory = { hasManyBy: { } } -export type GridTag = { +export type GridTag = { name: 'GridTag' unique: - | { id: string } - | { slug: string } + | Omit<{ id: string}, OverRelation> + | Omit<{ slug: string}, OverRelation> columns: { id: string name: string @@ -141,11 +163,11 @@ export type GridTag = { hasManyBy: { } } -export type InputRoot = { +export type InputRoot = { name: 'InputRoot' unique: - | { id: string } - | { unique: InputUnique } + | Omit<{ id: string}, OverRelation> + | Omit<{ unique: InputUnique}, OverRelation> columns: { id: string unique: InputUnique @@ -166,12 +188,12 @@ export type InputRoot = { hasManyBy: { } } -export type InputRules = { +export type InputRules = { name: 'InputRules' unique: - | { id: string } - | { unique: InputUnique } - | { uniqueValue: string } + | Omit<{ id: string}, OverRelation> + | Omit<{ unique: InputUnique}, OverRelation> + | Omit<{ uniqueValue: string}, OverRelation> columns: { id: string unique: InputUnique @@ -186,10 +208,10 @@ export type InputRules = { hasManyBy: { } } -export type RepeaterItem = { +export type RepeaterItem = { name: 'RepeaterItem' unique: - | { id: string } + | Omit<{ id: string}, OverRelation> columns: { id: string title: string @@ -202,10 +224,10 @@ export type RepeaterItem = { hasManyBy: { } } -export type SelectItem = { +export type SelectItem = { name: 'SelectItem' unique: - | { id: string } + | Omit<{ id: string}, OverRelation> columns: { id: string order: number | null @@ -219,13 +241,13 @@ export type SelectItem = { hasManyBy: { } } -export type SelectRoot = { +export type SelectRoot = { name: 'SelectRoot' unique: - | { id: string } - | { unique: SelectUnique } - | { hasOne: SelectValue['unique'] } - | { hasManySorted: SelectItem['unique'] } + | Omit<{ id: string}, OverRelation> + | Omit<{ unique: SelectUnique}, OverRelation> + | Omit<{ hasOne: SelectValue['unique']}, OverRelation> + | Omit<{ hasManySorted: SelectItem['unique']}, OverRelation> columns: { id: string unique: SelectUnique @@ -235,16 +257,16 @@ export type SelectRoot = { } hasMany: { hasMany: SelectValue - hasManySorted: SelectItem + hasManySorted: SelectItem<'root'> } hasManyBy: { } } -export type SelectValue = { +export type SelectValue = { name: 'SelectValue' unique: - | { id: string } - | { slug: string } + | Omit<{ id: string}, OverRelation> + | Omit<{ slug: string}, OverRelation> columns: { id: string name: string @@ -257,12 +279,275 @@ export type SelectValue = { hasManyBy: { } } +export type UploadAudio = { + name: 'UploadAudio' + unique: + | Omit<{ id: string}, OverRelation> + | Omit<{ meta: UploadFileMetadata['unique']}, OverRelation> + columns: { + id: string + url: string | null + duration: number | null + } + hasOne: { + meta: UploadFileMetadata + } + hasMany: { + } + hasManyBy: { + } +} +export type UploadFile = { + name: 'UploadFile' + unique: + | Omit<{ id: string}, OverRelation> + | Omit<{ meta: UploadFileMetadata['unique']}, OverRelation> + columns: { + id: string + url: string | null + } + hasOne: { + meta: UploadFileMetadata + } + hasMany: { + } + hasManyBy: { + } +} +export type UploadFileMetadata = { + name: 'UploadFileMetadata' + unique: + | Omit<{ id: string}, OverRelation> + columns: { + id: string + fileName: string | null + lastModified: string | null + fileSize: number | null + fileType: string | null + } + hasOne: { + } + hasMany: { + } + hasManyBy: { + } +} +export type UploadGallery = { + name: 'UploadGallery' + unique: + | Omit<{ id: string}, OverRelation> + | Omit<{ items: UploadGalleryItem['unique']}, OverRelation> + columns: { + id: string + } + hasOne: { + } + hasMany: { + items: UploadGalleryItem<'gallery'> + } + hasManyBy: { + itemsByImage: { entity: UploadGalleryItem; by: {image: UploadImage['unique']} } + itemsByVideo: { entity: UploadGalleryItem; by: {video: UploadVideo['unique']} } + itemsByAudio: { entity: UploadGalleryItem; by: {audio: UploadAudio['unique']} } + itemsByFile: { entity: UploadGalleryItem; by: {file: UploadFile['unique']} } + } +} +export type UploadGalleryItem = { + name: 'UploadGalleryItem' + unique: + | Omit<{ id: string}, OverRelation> + | Omit<{ image: UploadImage['unique']}, OverRelation> + | Omit<{ video: UploadVideo['unique']}, OverRelation> + | Omit<{ audio: UploadAudio['unique']}, OverRelation> + | Omit<{ file: UploadFile['unique']}, OverRelation> + columns: { + id: string + type: UploadMediaType + } + hasOne: { + gallery: UploadGallery + image: UploadImage + video: UploadVideo + audio: UploadAudio + file: UploadFile + } + hasMany: { + } + hasManyBy: { + } +} +export type UploadImage = { + name: 'UploadImage' + unique: + | Omit<{ id: string}, OverRelation> + | Omit<{ meta: UploadFileMetadata['unique']}, OverRelation> + columns: { + id: string + url: string | null + width: number | null + height: number | null + alt: string | null + } + hasOne: { + meta: UploadFileMetadata + } + hasMany: { + } + hasManyBy: { + } +} +export type UploadImageList = { + name: 'UploadImageList' + unique: + | Omit<{ id: string}, OverRelation> + | Omit<{ items: UploadImageListItem['unique']}, OverRelation> + columns: { + id: string + } + hasOne: { + } + hasMany: { + items: UploadImageListItem<'list'> + } + hasManyBy: { + itemsByImage: { entity: UploadImageListItem; by: {image: UploadImage['unique']} } + } +} +export type UploadImageListItem = { + name: 'UploadImageListItem' + unique: + | Omit<{ id: string}, OverRelation> + | Omit<{ image: UploadImage['unique']}, OverRelation> + columns: { + id: string + order: number + } + hasOne: { + list: UploadImageList + image: UploadImage + } + hasMany: { + } + hasManyBy: { + } +} +export type UploadList = { + name: 'UploadList' + unique: + | Omit<{ id: string}, OverRelation> + | Omit<{ items: UploadListItem['unique']}, OverRelation> + columns: { + id: string + } + hasOne: { + } + hasMany: { + items: UploadListItem<'list'> + } + hasManyBy: { + } +} +export type UploadListItem = { + name: 'UploadListItem' + unique: + | Omit<{ id: string}, OverRelation> + columns: { + id: string + order: number + } + hasOne: { + list: UploadList + item: UploadMedium + } + hasMany: { + } + hasManyBy: { + } +} +export type UploadMedium = { + name: 'UploadMedium' + unique: + | Omit<{ id: string}, OverRelation> + | Omit<{ image: UploadImage['unique']}, OverRelation> + | Omit<{ video: UploadVideo['unique']}, OverRelation> + | Omit<{ audio: UploadAudio['unique']}, OverRelation> + | Omit<{ file: UploadFile['unique']}, OverRelation> + columns: { + id: string + type: UploadMediaType + } + hasOne: { + image: UploadImage + video: UploadVideo + audio: UploadAudio + file: UploadFile + } + hasMany: { + } + hasManyBy: { + } +} +export type UploadRoot = { + name: 'UploadRoot' + unique: + | Omit<{ id: string}, OverRelation> + | Omit<{ unique: UploadOne}, OverRelation> + | Omit<{ image: UploadImage['unique']}, OverRelation> + | Omit<{ audio: UploadAudio['unique']}, OverRelation> + | Omit<{ video: UploadVideo['unique']}, OverRelation> + | Omit<{ file: UploadFile['unique']}, OverRelation> + | Omit<{ imageTrivial: UploadImage['unique']}, OverRelation> + | Omit<{ imageList: UploadImageList['unique']}, OverRelation> + | Omit<{ medium: UploadMedium['unique']}, OverRelation> + | Omit<{ gallery: UploadGallery['unique']}, OverRelation> + | Omit<{ list: UploadList['unique']}, OverRelation> + columns: { + id: string + unique: UploadOne + } + hasOne: { + image: UploadImage + audio: UploadAudio + video: UploadVideo + file: UploadFile + imageTrivial: UploadImage + imageList: UploadImageList + medium: UploadMedium + gallery: UploadGallery + list: UploadList + } + hasMany: { + } + hasManyBy: { + } +} +export type UploadVideo = { + name: 'UploadVideo' + unique: + | Omit<{ id: string}, OverRelation> + | Omit<{ meta: UploadFileMetadata['unique']}, OverRelation> + columns: { + id: string + url: string | null + width: number | null + height: number | null + duration: number | null + } + hasOne: { + meta: UploadFileMetadata + } + hasMany: { + } + hasManyBy: { + } +} export type ContemberClientEntities = { BoardTag: BoardTag BoardTask: BoardTask BoardUser: BoardUser GridArticle: GridArticle + GridArticleComment: GridArticleComment GridAuthor: GridAuthor GridCategory: GridCategory GridTag: GridTag @@ -272,6 +557,19 @@ export type ContemberClientEntities = { SelectItem: SelectItem SelectRoot: SelectRoot SelectValue: SelectValue + UploadAudio: UploadAudio + UploadFile: UploadFile + UploadFileMetadata: UploadFileMetadata + UploadGallery: UploadGallery + UploadGalleryItem: UploadGalleryItem + UploadImage: UploadImage + UploadImageList: UploadImageList + UploadImageListItem: UploadImageListItem + UploadList: UploadList + UploadListItem: UploadListItem + UploadMedium: UploadMedium + UploadRoot: UploadRoot + UploadVideo: UploadVideo } export type ContemberClientSchema = { diff --git a/packages/playground/api/client/enums.ts b/packages/playground/api/client/enums.ts index 5b3a5cbac..07a2aabd8 100644 --- a/packages/playground/api/client/enums.ts +++ b/packages/playground/api/client/enums.ts @@ -11,6 +11,13 @@ export type InputUnique = | "unique" export type SelectUnique = | "unique" +export type UploadMediaType = + | "image" + | "video" + | "audio" + | "file" +export type UploadOne = + | "unique" export type InputRootEnumValue = | "a" | "b" diff --git a/packages/playground/api/client/names.ts b/packages/playground/api/client/names.ts index 4fb8276e2..d6c319b0e 100644 --- a/packages/playground/api/client/names.ts +++ b/packages/playground/api/client/names.ts @@ -120,6 +120,10 @@ export const ContemberClientNames: SchemaNames = { }, "views": { "type": "column" + }, + "comments": { + "type": "many", + "entity": "GridArticleComment" } }, "scalars": [ @@ -133,6 +137,33 @@ export const ContemberClientNames: SchemaNames = { "views" ] }, + "GridArticleComment": { + "name": "GridArticleComment", + "fields": { + "id": { + "type": "column" + }, + "article": { + "type": "one", + "entity": "GridArticle" + }, + "author": { + "type": "one", + "entity": "GridAuthor" + }, + "content": { + "type": "column" + }, + "createdAt": { + "type": "column" + } + }, + "scalars": [ + "id", + "content", + "createdAt" + ] + }, "GridAuthor": { "name": "GridAuthor", "fields": { @@ -355,6 +386,345 @@ export const ContemberClientNames: SchemaNames = { "name", "slug" ] + }, + "UploadAudio": { + "name": "UploadAudio", + "fields": { + "id": { + "type": "column" + }, + "url": { + "type": "column" + }, + "duration": { + "type": "column" + }, + "meta": { + "type": "one", + "entity": "UploadFileMetadata" + } + }, + "scalars": [ + "id", + "url", + "duration" + ] + }, + "UploadFile": { + "name": "UploadFile", + "fields": { + "id": { + "type": "column" + }, + "url": { + "type": "column" + }, + "meta": { + "type": "one", + "entity": "UploadFileMetadata" + } + }, + "scalars": [ + "id", + "url" + ] + }, + "UploadFileMetadata": { + "name": "UploadFileMetadata", + "fields": { + "id": { + "type": "column" + }, + "fileName": { + "type": "column" + }, + "lastModified": { + "type": "column" + }, + "fileSize": { + "type": "column" + }, + "fileType": { + "type": "column" + } + }, + "scalars": [ + "id", + "fileName", + "lastModified", + "fileSize", + "fileType" + ] + }, + "UploadGallery": { + "name": "UploadGallery", + "fields": { + "id": { + "type": "column" + }, + "items": { + "type": "many", + "entity": "UploadGalleryItem" + } + }, + "scalars": [ + "id" + ] + }, + "UploadGalleryItem": { + "name": "UploadGalleryItem", + "fields": { + "id": { + "type": "column" + }, + "gallery": { + "type": "one", + "entity": "UploadGallery" + }, + "type": { + "type": "column" + }, + "image": { + "type": "one", + "entity": "UploadImage" + }, + "video": { + "type": "one", + "entity": "UploadVideo" + }, + "audio": { + "type": "one", + "entity": "UploadAudio" + }, + "file": { + "type": "one", + "entity": "UploadFile" + } + }, + "scalars": [ + "id", + "type" + ] + }, + "UploadImage": { + "name": "UploadImage", + "fields": { + "id": { + "type": "column" + }, + "url": { + "type": "column" + }, + "width": { + "type": "column" + }, + "height": { + "type": "column" + }, + "alt": { + "type": "column" + }, + "meta": { + "type": "one", + "entity": "UploadFileMetadata" + } + }, + "scalars": [ + "id", + "url", + "width", + "height", + "alt" + ] + }, + "UploadImageList": { + "name": "UploadImageList", + "fields": { + "id": { + "type": "column" + }, + "items": { + "type": "many", + "entity": "UploadImageListItem" + } + }, + "scalars": [ + "id" + ] + }, + "UploadImageListItem": { + "name": "UploadImageListItem", + "fields": { + "id": { + "type": "column" + }, + "list": { + "type": "one", + "entity": "UploadImageList" + }, + "order": { + "type": "column" + }, + "image": { + "type": "one", + "entity": "UploadImage" + } + }, + "scalars": [ + "id", + "order" + ] + }, + "UploadList": { + "name": "UploadList", + "fields": { + "id": { + "type": "column" + }, + "items": { + "type": "many", + "entity": "UploadListItem" + } + }, + "scalars": [ + "id" + ] + }, + "UploadListItem": { + "name": "UploadListItem", + "fields": { + "id": { + "type": "column" + }, + "list": { + "type": "one", + "entity": "UploadList" + }, + "order": { + "type": "column" + }, + "item": { + "type": "one", + "entity": "UploadMedium" + } + }, + "scalars": [ + "id", + "order" + ] + }, + "UploadMedium": { + "name": "UploadMedium", + "fields": { + "id": { + "type": "column" + }, + "type": { + "type": "column" + }, + "image": { + "type": "one", + "entity": "UploadImage" + }, + "video": { + "type": "one", + "entity": "UploadVideo" + }, + "audio": { + "type": "one", + "entity": "UploadAudio" + }, + "file": { + "type": "one", + "entity": "UploadFile" + } + }, + "scalars": [ + "id", + "type" + ] + }, + "UploadRoot": { + "name": "UploadRoot", + "fields": { + "id": { + "type": "column" + }, + "unique": { + "type": "column" + }, + "image": { + "type": "one", + "entity": "UploadImage" + }, + "audio": { + "type": "one", + "entity": "UploadAudio" + }, + "video": { + "type": "one", + "entity": "UploadVideo" + }, + "file": { + "type": "one", + "entity": "UploadFile" + }, + "imageTrivial": { + "type": "one", + "entity": "UploadImage" + }, + "imageList": { + "type": "one", + "entity": "UploadImageList" + }, + "medium": { + "type": "one", + "entity": "UploadMedium" + }, + "gallery": { + "type": "one", + "entity": "UploadGallery" + }, + "list": { + "type": "one", + "entity": "UploadList" + } + }, + "scalars": [ + "id", + "unique" + ] + }, + "UploadVideo": { + "name": "UploadVideo", + "fields": { + "id": { + "type": "column" + }, + "url": { + "type": "column" + }, + "width": { + "type": "column" + }, + "height": { + "type": "column" + }, + "duration": { + "type": "column" + }, + "meta": { + "type": "one", + "entity": "UploadFileMetadata" + } + }, + "scalars": [ + "id", + "url", + "width", + "height", + "duration" + ] } } } \ No newline at end of file diff --git a/packages/playground/api/migrations/2024-03-20-101401-grid-comment.json b/packages/playground/api/migrations/2024-03-20-101401-grid-comment.json new file mode 100644 index 000000000..d02f97da3 --- /dev/null +++ b/packages/playground/api/migrations/2024-03-20-101401-grid-comment.json @@ -0,0 +1,90 @@ +{ + "formatVersion": 5, + "modifications": [ + { + "modification": "removeField", + "entityName": "UploadFile", + "fieldName": "duration" + }, + { + "modification": "createEntity", + "entity": { + "name": "GridArticleComment", + "primary": "id", + "primaryColumn": "id", + "tableName": "grid_article_comment", + "fields": { + "id": { + "name": "id", + "columnName": "id", + "columnType": "uuid", + "nullable": false, + "type": "Uuid" + } + }, + "unique": [], + "indexes": [], + "eventLog": { + "enabled": true + } + } + }, + { + "modification": "createColumn", + "entityName": "GridArticleComment", + "field": { + "name": "content", + "columnName": "content", + "columnType": "text", + "nullable": true, + "type": "String" + } + }, + { + "modification": "createColumn", + "entityName": "GridArticleComment", + "field": { + "name": "createdAt", + "columnName": "created_at", + "columnType": "timestamptz", + "nullable": true, + "type": "DateTime" + } + }, + { + "modification": "createRelation", + "entityName": "GridArticleComment", + "owningSide": { + "type": "ManyHasOne", + "name": "article", + "target": "GridArticle", + "joiningColumn": { + "columnName": "article_id", + "onDelete": "cascade" + }, + "nullable": false, + "inversedBy": "comments" + }, + "inverseSide": { + "type": "OneHasMany", + "name": "comments", + "target": "GridArticleComment", + "ownedBy": "article" + } + }, + { + "modification": "createRelation", + "entityName": "GridArticleComment", + "owningSide": { + "type": "ManyHasOne", + "name": "author", + "target": "GridAuthor", + "joiningColumn": { + "columnName": "author_id", + "onDelete": "cascade" + }, + "nullable": false + } + } + ] +} diff --git a/packages/playground/api/migrations/2024-03-20-111500-grid-comments-data.ts b/packages/playground/api/migrations/2024-03-20-111500-grid-comments-data.ts new file mode 100644 index 000000000..27ac93f59 --- /dev/null +++ b/packages/playground/api/migrations/2024-03-20-111500-grid-comments-data.ts @@ -0,0 +1,51 @@ +import { printMutation } from './utils' +import { queryBuilder } from '../client' +import { faker } from '@faker-js/faker' + +faker.seed(123) + +const authors = [ + 'john-doe', + 'jane-doe', + 'jack-black', +] + +const articles = [ + 'hello-world-article-0', + 'hello-world-article-1', + 'hello-world-article-2', + 'hello-world-article-3', + 'hello-world-article-5', + 'hello-world-article-6', + 'hello-world-article-7', + 'hello-world-article-8', + 'hello-world-article-9', + 'hello-world-article-10', +] + +export default printMutation([ + ...Array.from({ length: 60 }).map((_, i) => { + return queryBuilder.create('GridArticleComment', { + data: { + content: faker.lorem.paragraph(), + createdAt: faker.date.recent().toISOString(), + article: { + connect: { + slug: articles[faker.number.int({ + min: 0, + max: articles.length - 1, + })], + }, + }, + author: { + connect: { + slug: authors[faker.number.int({ + min: 0, + max: authors.length - 1, + })], + }, + }, + }, + }) + }), +]) diff --git a/packages/playground/api/model/Grid.ts b/packages/playground/api/model/Grid.ts index 63d0b135e..9b8670245 100644 --- a/packages/playground/api/model/Grid.ts +++ b/packages/playground/api/model/Grid.ts @@ -13,6 +13,7 @@ export class GridArticle { category = c.manyHasOne(GridCategory) tags = c.manyHasMany(GridTag) views = c.intColumn() + comments = c.oneHasMany(GridArticleComment, 'article') } export class GridTag { @@ -29,3 +30,10 @@ export class GridAuthor { name = c.stringColumn().notNull() slug = c.stringColumn().notNull().unique() } + +export class GridArticleComment { + article = c.manyHasOne(GridArticle, 'comments').notNull().cascadeOnDelete() + author = c.manyHasOne(GridAuthor).notNull().cascadeOnDelete() + content = c.stringColumn() + createdAt = c.dateTimeColumn() +} diff --git a/packages/playground/api/tsconfig.json b/packages/playground/api/tsconfig.json index c5c526e0d..eb94116ea 100644 --- a/packages/playground/api/tsconfig.json +++ b/packages/playground/api/tsconfig.json @@ -4,7 +4,13 @@ "outDir": "../dist/types" }, "references": [ - { "path": "../../admin/src" }, + { "path": "../../interface/src" }, + { "path": "../../react-board/src" }, + { "path": "../../react-board-dnd-kit/src" }, + { "path": "../../react-repeater/src" }, + { "path": "../../react-repeater-dnd-kit/src" }, + { "path": "../../react-select/src" }, + { "path": "../../react-dataview/src" }, { "path": "../../react-uploader/src" }, { "path": "../../react-uploader-dropzone/src" }, ] diff --git a/packages/react-datagrid/package.json b/packages/react-datagrid/package.json index 1f0f2a16c..0beb6a5bf 100644 --- a/packages/react-datagrid/package.json +++ b/packages/react-datagrid/package.json @@ -40,6 +40,7 @@ "directory": "packages/react-datagrid" }, "dependencies": { + "@contember/client": "workspace:*", "@contember/react-binding": "workspace:*", "@contember/react-choice-field": "workspace:*", "@contember/react-dataview": "workspace:*", diff --git a/packages/react-datagrid/src/cells/CoalesceTextCell.tsx b/packages/react-datagrid/src/cells/CoalesceTextCell.tsx index 6c036d303..f2dfeb8d5 100644 --- a/packages/react-datagrid/src/cells/CoalesceTextCell.tsx +++ b/packages/react-datagrid/src/cells/CoalesceTextCell.tsx @@ -2,30 +2,30 @@ import { Component, SugarableRelativeSingleField } from '@contember/react-bindin import { ComponentType, FunctionComponent } from 'react' import { DataGridColumnCommonProps, FilterRendererProps } from '../types' import { DataGridColumn } from '../grid' -import { CoalesceTextFilterArtifacts, createCoalesceFilter } from '@contember/react-dataview' +import { TextFilterArtifacts, createUnionTextFilter } from '@contember/react-dataview' export type CoalesceCellRendererProps = { fields: (SugarableRelativeSingleField | string)[] - initialFilter?: CoalesceTextFilterArtifacts + initialFilter?: TextFilterArtifacts } export type CoalesceTextCellProps = & DataGridColumnCommonProps & CoalesceCellRendererProps & { - initialFilter?: CoalesceTextFilterArtifacts + initialFilter?: TextFilterArtifacts } export const createCoalesceTextCell = ({ FilterRenderer, ValueRenderer }: { - FilterRenderer: ComponentType>, + FilterRenderer: ComponentType>, ValueRenderer: ComponentType }): FunctionComponent => Component(props => { return ( - + {...props} enableOrdering={false} - getNewFilter={createCoalesceFilter(props.fields)} + getNewFilter={createUnionTextFilter(props.fields)} emptyFilter={{ mode: 'matches', query: '', diff --git a/packages/react-datagrid/src/grid/createDataGrid.tsx b/packages/react-datagrid/src/grid/createDataGrid.tsx index ef3210b31..ec3cad76d 100644 --- a/packages/react-datagrid/src/grid/createDataGrid.tsx +++ b/packages/react-datagrid/src/grid/createDataGrid.tsx @@ -5,6 +5,7 @@ import { ControlledDataGridProps, createControlledDataGrid } from './createContr import { extractDataGridColumns } from '../internal/gridTemplateAnalyzer' import { useDataGrid } from './useDataGrid' import { DataGridColumnsContext } from '../internal/contexts' +import { replaceGraphQlLiteral } from '@contember/client' export type DataGridProps

= & { @@ -53,7 +54,7 @@ const dummyStateMethods: DataGridMethods = { const createInitialState = (props: DataGridProps<{}>, environment: Environment): DataGridState => { const entities = QueryLanguage.desugarQualifiedEntityList({ entities: props.entities }, environment) - const filter: Filter = { and: [entities.filter ?? {}] } + const filter = resolveFilter({ and: [entities.filter ?? {}] }) return { key: '_', paging: { @@ -76,3 +77,6 @@ const createInitialState = (props: DataGridProps<{}>, environment: Environment): entities: entities, } } +const resolveFilter = (input?: Filter): Filter => { + return replaceGraphQlLiteral(input) as Filter +} diff --git a/packages/react-datagrid/src/tsconfig.json b/packages/react-datagrid/src/tsconfig.json index de803c39d..d22973fc9 100644 --- a/packages/react-datagrid/src/tsconfig.json +++ b/packages/react-datagrid/src/tsconfig.json @@ -4,6 +4,7 @@ "outDir": "../dist/types" }, "references": [ + { "path": "../../client/src" }, { "path": "../../react-binding/src" }, { "path": "../../react-choice-field/src" }, { "path": "../../react-dataview/src" }, diff --git a/packages/react-dataview/package.json b/packages/react-dataview/package.json index 4374dee03..465bf235c 100644 --- a/packages/react-dataview/package.json +++ b/packages/react-dataview/package.json @@ -44,6 +44,7 @@ "@contember/client": "workspace:*", "@contember/react-binding": "workspace:*", "@contember/react-client": "workspace:*", + "@contember/react-multipass-rendering": "workspace:*", "@contember/react-utils": "workspace:*", "@contember/utilities": "workspace:*", "@radix-ui/react-slot": "^1.0.2" diff --git a/packages/react-dataview/src/components/DataView.tsx b/packages/react-dataview/src/components/DataView.tsx index be42c3b19..e60b4858c 100644 --- a/packages/react-dataview/src/components/DataView.tsx +++ b/packages/react-dataview/src/components/DataView.tsx @@ -1,10 +1,13 @@ -import { ReactNode } from 'react' -import { Component, QueryLanguage } from '@contember/react-binding' +import { ReactNode, useState } from 'react' +import { Component, EntityListSubTree, QueryLanguage } from '@contember/react-binding' import { useDataView, UseDataViewArgs } from '../hooks' import { ControlledDataView } from './ControlledDataView' import { DataViewLoader } from '../internal/components/DataViewLoader' import { DATA_VIEW_DEFAULT_ITEMS_PER_PAGE } from '../internal/hooks/useDataViewPaging' -import { EntityAccessor } from '@contember/binding' +import { EntityAccessor, Environment } from '@contember/binding' +import { ChildrenAnalyzer, Leaf } from '@contember/react-multipass-rendering' +import { DataViewFilter, DataViewFilterProps } from './filtering' +import { dataViewSelectionEnvironmentExtension } from '../dataViewSelectionEnvironmentExtension' export type DataViewProps = @@ -14,8 +17,20 @@ export type DataViewProps = } & UseDataViewArgs -export const DataView = Component(props => { - const { state, methods, info } = useDataView(props) +export const DataView = Component((props, env) => { + const [filterTypes] = useState(() => { + const state = resolveInitialState(props, env) + const result = dataViewFilterAnalyzer.processChildren( + {props.children}, + env.withExtension(dataViewSelectionEnvironmentExtension, state.selection), + ) + return { + ...Object.fromEntries(result.map(it => [it.name, it.filterHandler])), + ...props.filterTypes, + } + }) + + const { state, methods, info } = useDataView({ ...props, filterTypes }) return ( @@ -24,28 +39,45 @@ export const DataView = Component(props => { ) }, (props, env) => { return ( - + ) }) + +const resolveInitialState = (props: DataViewProps, env: Environment) => { + return { + key: '_', + entities: QueryLanguage.desugarQualifiedEntityList({ entities: props.entities }, env), + paging: { + pageIndex: 0, + itemsPerPage: props.initialItemsPerPage ?? DATA_VIEW_DEFAULT_ITEMS_PER_PAGE, + }, + filtering: { + filter: { + and: [{}], + }, + filterTypes: {}, + artifact: {}, + }, + sorting: { + orderBy: [], + directions: {}, + }, + selection: { + values: props.initialSelection && typeof props.initialSelection !== 'function' ? props.initialSelection : {}, + fallback: props.selectionFallback === undefined ? true : props.selectionFallback, + }, + } +} + + +const filterLeaf = new Leaf(node => node.props, DataViewFilter) + + +export const dataViewFilterAnalyzer = new ChildrenAnalyzer< + DataViewFilterProps, + never, + Environment +>([filterLeaf], { + staticRenderFactoryName: 'staticRender', + staticContextFactoryName: 'generateEnvironment', +}) diff --git a/packages/react-dataview/src/components/DataViewExportTrigger.tsx b/packages/react-dataview/src/components/DataViewExportTrigger.tsx new file mode 100644 index 000000000..b6668875d --- /dev/null +++ b/packages/react-dataview/src/components/DataViewExportTrigger.tsx @@ -0,0 +1,50 @@ +import * as React from 'react' +import { ReactElement, ReactNode, useCallback } from 'react' +import { Slot } from '@radix-ui/react-slot' +import { useDataViewFetchAllData } from '../hooks' +import { CsvExportFactory, ExportFactory } from '../export' +import { useDataViewEntityListProps } from '../contexts' + + +export interface DataViewExportTriggerProps { + fields: ReactNode + children: ReactElement + baseName?: string + exportFactory?: ExportFactory +} + +const defaultExportFactory = new CsvExportFactory() + + +export const DataViewExportTrigger = ({ fields, children, baseName, exportFactory }: DataViewExportTriggerProps) => { + const entityName = useDataViewEntityListProps().entityName + const fetchData = useDataViewFetchAllData({ children: fields }) + const download = useTriggerDownload() + const doExport = useCallback(async () => { + + const fetchResult = await fetchData() + const exportedData = (exportFactory ?? defaultExportFactory).create(fetchResult) + + const baseNameResolved = baseName ?? `${entityName}-${new Date().toISOString().split('T')[0]}` + download(exportedData.blob, `${baseNameResolved}.${exportedData.extension}`) + + }, [baseName, download, entityName, exportFactory, fetchData]) + + return ( + + {children} + + ) +} + + +const useTriggerDownload = () => { + return useCallback((blob: Blob, fileName: string) => { + const a = document.createElement('a') + a.href = URL.createObjectURL(blob) + a.download = fileName + document.body.appendChild(a) + a.click() + document.body.removeChild(a) + }, []) +} diff --git a/packages/react-dataview/src/components/filtering/DataViewFilter.tsx b/packages/react-dataview/src/components/filtering/DataViewFilter.tsx new file mode 100644 index 000000000..8e165ecbf --- /dev/null +++ b/packages/react-dataview/src/components/filtering/DataViewFilter.tsx @@ -0,0 +1,9 @@ +import { DataViewFilterHandler } from '../../types' + +export type DataViewFilterProps = { + name: string + filterHandler: DataViewFilterHandler +} +export const DataViewFilter = ({}: DataViewFilterProps) => { + throw new Error('Should not render') +} diff --git a/packages/react-dataview/src/components/filtering/DataViewHasFilterType.tsx b/packages/react-dataview/src/components/filtering/DataViewHasFilterType.tsx new file mode 100644 index 000000000..ab3635a83 --- /dev/null +++ b/packages/react-dataview/src/components/filtering/DataViewHasFilterType.tsx @@ -0,0 +1,10 @@ +import { useDataViewFilterHandlerRegistry } from '../../contexts' + +export interface DataViewHasFilterTypeProps { + name: string + children: React.ReactNode +} +export const DataViewHasFilterType = ({ name, children }: DataViewHasFilterTypeProps) => { + const types = useDataViewFilterHandlerRegistry() + return types[name] ? <>{children} : null +} diff --git a/packages/react-dataview/src/components/filtering/index.ts b/packages/react-dataview/src/components/filtering/index.ts index 71b662f03..f7fdf4c96 100644 --- a/packages/react-dataview/src/components/filtering/index.ts +++ b/packages/react-dataview/src/components/filtering/index.ts @@ -5,3 +5,5 @@ export * from './date' export * from './number' export * from './nullCondition' export * from './text' +export * from './DataViewHasFilterType' +export * from './DataViewFilter' diff --git a/packages/react-dataview/src/components/filtering/text/DataViewTextFilterInput.tsx b/packages/react-dataview/src/components/filtering/text/DataViewTextFilterInput.tsx index f7ada8609..c9aacdb0d 100644 --- a/packages/react-dataview/src/components/filtering/text/DataViewTextFilterInput.tsx +++ b/packages/react-dataview/src/components/filtering/text/DataViewTextFilterInput.tsx @@ -5,9 +5,10 @@ import { useDataViewTextFilterInput } from '../../../hooks' const SlotInput = Slot as ComponentType> -export const DataViewTextFilterInput = ({ name, ...props }: { +export const DataViewTextFilterInput = ({ name, debounceMs, ...props }: { name: string + debounceMs?: number children: ReactElement }) => { - return + return } diff --git a/packages/react-dataview/src/components/index.ts b/packages/react-dataview/src/components/index.ts index 1e0ad3fbe..2798ed0ed 100644 --- a/packages/react-dataview/src/components/index.ts +++ b/packages/react-dataview/src/components/index.ts @@ -7,6 +7,7 @@ export * from './filtering' export * from './ControlledDataView' export * from './DataView' export * from './DataViewEachRow' +export * from './DataViewExportTrigger' export * from './DataViewEmpty' export * from './selection/DataViewHasSelection' export * from './DataViewKeyProvider' diff --git a/packages/react-dataview/src/export/CsvExportFactory.ts b/packages/react-dataview/src/export/CsvExportFactory.ts new file mode 100644 index 000000000..65f87de37 --- /dev/null +++ b/packages/react-dataview/src/export/CsvExportFactory.ts @@ -0,0 +1,92 @@ +import { EntityListSubTreeMarker, FieldMarker, HasManyRelationMarker, HasOneRelationMarker, PRIMARY_KEY_NAME } from '@contember/binding' +import { ExportFactory, ExportFormatterCreateOutputArgs, ExportResult } from './ExportFactory' +import { DataViewDataForExport } from '../types' + +export class CsvExportFactory implements ExportFactory { + + create(args: ExportFormatterCreateOutputArgs): ExportResult { + const data = this.flattenData(args.data, args.marker) + const filteredData = this.filterData(data) + const string = this.formatOutput(filteredData) + + return { + blob: new Blob([string], { type: 'text/csv' }), + extension: 'csv', + } + } + + protected filterData(data: DataViewDataForExport): DataViewDataForExport { + return data.filter(it => { + const lastMarker = it.markerPath[it.markerPath.length - 1] + return (lastMarker instanceof FieldMarker) && (lastMarker.fieldName !== PRIMARY_KEY_NAME || it.markerPath.length === 2) + }) + } + + protected flattenData(data: any[], marker: EntityListSubTreeMarker | HasOneRelationMarker | HasManyRelationMarker) { + const columns: DataViewDataForExport = [] + const traverseMarkers = (data: any[], markerPath: (EntityListSubTreeMarker | HasOneRelationMarker | HasManyRelationMarker)[]) => { + for (const subMarker of markerPath[markerPath.length - 1].fields.markers.values()) { + const values = data.map((it: any) => + Array.isArray(it) + ? it.flatMap(it => it?.[subMarker.placeholderName]) + : it?.[subMarker.placeholderName], + ) + + if (subMarker instanceof FieldMarker) { + columns.push({ + markerPath: [...markerPath, subMarker], + values, + }) + } else if (subMarker instanceof HasOneRelationMarker || subMarker instanceof HasManyRelationMarker) { + traverseMarkers(values, [...markerPath, subMarker]) + } + } + } + traverseMarkers(data, [marker]) + + + return columns + } + + protected formatValue(value: any) { + const stringValue = Array.isArray(value) ? value.join(';') : String((value ?? '')) + if (stringValue.includes(',') || stringValue.includes('\n')) { + return '"' + stringValue.replace(/"/g, '""') + '"' + } + return stringValue + } + + protected formatOutput(data: DataViewDataForExport): string { + const rows = this.createData(data).map(it => it.join(',')) + const header = this.createHeader(data).join(',') + return header + '\n' + rows.join('\n') + } + + protected createHeader(data: DataViewDataForExport) { + return data.map(it => { + return it.markerPath.map(it => { + if (it instanceof FieldMarker) { + return it.placeholderName + } else if (it instanceof HasOneRelationMarker || it instanceof HasManyRelationMarker) { + return it.parameters.field + } else { + return null + } + }).filter(it => it !== null).join(' ') + }) + } + + protected createData(data: DataViewDataForExport) { + const rows: string[][] = [] + + const maxLength = Math.max(...data.map(it => it.values.length)) + for (let i = 0; i < maxLength; i++) { + const row = data.map(it => { + const value = it.values[i] + return this.formatValue(value) + }) + rows.push(row) + } + return rows + } +} diff --git a/packages/react-dataview/src/export/ExportFactory.ts b/packages/react-dataview/src/export/ExportFactory.ts new file mode 100644 index 000000000..72151c914 --- /dev/null +++ b/packages/react-dataview/src/export/ExportFactory.ts @@ -0,0 +1,15 @@ +import { EntityListSubTreeMarker, HasManyRelationMarker, HasOneRelationMarker } from '@contember/binding' + +export interface ExportFormatterCreateOutputArgs { + data: any[] + marker: EntityListSubTreeMarker | HasOneRelationMarker | HasManyRelationMarker +} + +export interface ExportResult { + blob: Blob + extension: string +} + +export interface ExportFactory { + create(args: ExportFormatterCreateOutputArgs): ExportResult +} diff --git a/packages/react-dataview/src/export/index.ts b/packages/react-dataview/src/export/index.ts new file mode 100644 index 000000000..efda1bf52 --- /dev/null +++ b/packages/react-dataview/src/export/index.ts @@ -0,0 +1,2 @@ +export * from './CsvExportFactory' +export * from './ExportFactory' diff --git a/packages/react-dataview/src/filterTypes/index.ts b/packages/react-dataview/src/filterTypes/index.ts index 80dd99711..b245cb60c 100644 --- a/packages/react-dataview/src/filterTypes/index.ts +++ b/packages/react-dataview/src/filterTypes/index.ts @@ -1,5 +1,4 @@ export * from './boolean' -export * from './coalesce' export * from './common' export * from './date' export * from './enum' @@ -8,3 +7,4 @@ export * from './hasOne' export * from './number' export * from './numberRange' export * from './text' +export * from './unionText' diff --git a/packages/react-dataview/src/filterTypes/coalesce.ts b/packages/react-dataview/src/filterTypes/unionText.ts similarity index 60% rename from packages/react-dataview/src/filterTypes/coalesce.ts rename to packages/react-dataview/src/filterTypes/unionText.ts index b7b773fa1..7ec941000 100644 --- a/packages/react-dataview/src/filterTypes/coalesce.ts +++ b/packages/react-dataview/src/filterTypes/unionText.ts @@ -2,13 +2,8 @@ import { Filter, SugaredRelativeSingleField } from '@contember/binding' import { DataViewFilterHandler } from '../types' import { createTextFilter, TextFilterArtifacts } from './text' -export type CoalesceTextFilterArtifacts = { - mode?: 'matches' | 'matchesExactly' | 'startsWith' | 'endsWith' | 'doesNotMatch' - query?: string -} - -export const createCoalesceFilter = (fields: SugaredRelativeSingleField['field'][]): DataViewFilterHandler => { +export const createUnionTextFilter = (fields: SugaredRelativeSingleField['field'][]): DataViewFilterHandler => { const filters = fields.map(it => createTextFilter(it)) return (filter, { environment }): Filter | undefined => { diff --git a/packages/react-dataview/src/hooks/filters/text/useDataViewTextFilterInput.ts b/packages/react-dataview/src/hooks/filters/text/useDataViewTextFilterInput.ts index c355a6761..53b619d36 100644 --- a/packages/react-dataview/src/hooks/filters/text/useDataViewTextFilterInput.ts +++ b/packages/react-dataview/src/hooks/filters/text/useDataViewTextFilterInput.ts @@ -1,4 +1,4 @@ -import { ChangeEvent, useCallback } from 'react' +import { ChangeEvent, useCallback, useEffect, useRef, useState } from 'react' import { useDataViewFilter } from '../../useDataViewFilter' import { TextFilterArtifacts } from '../../../filterTypes' @@ -7,17 +7,41 @@ export interface UseDataViewTextFilterInputResult { onChange: (e: ChangeEvent) => void } -export const useDataViewTextFilterInput = (name: string): UseDataViewTextFilterInputResult => { +export const useDataViewTextFilterInput = ({ name, debounceMs = 500 }: { name: string, debounceMs?: number }): UseDataViewTextFilterInputResult => { const [state, setFilter] = useDataViewFilter(name) + const [value, setValue] = useState(state?.query ?? '') + const timerRef = useRef>() + useEffect(() => { + if (!timerRef.current) { + setValue(state?.query ?? '') + } + }, [state?.query]) + const onChange = useCallback((e: ChangeEvent) => { - setFilter(it => ({ - ...it, - query: e.target.value, - })) - }, [setFilter]) + if (debounceMs && e.target.value) { + timerRef.current && clearTimeout(timerRef.current) + timerRef.current = undefined + + timerRef.current = setTimeout(() => { + timerRef.current = undefined + setFilter(it => ({ + ...it, + query: e.target.value, + })) + }, debounceMs) + + } else { + setFilter(it => ({ + ...it, + query: e.target.value, + })) + } + setValue(e.target.value) + + }, [debounceMs, setFilter]) return { - value: state?.query ?? '', + value, onChange, } } diff --git a/packages/react-dataview/src/hooks/index.ts b/packages/react-dataview/src/hooks/index.ts index 572285cfe..a8daa3932 100644 --- a/packages/react-dataview/src/hooks/index.ts +++ b/packages/react-dataview/src/hooks/index.ts @@ -1,4 +1,5 @@ export * from './filters' export * from './useDataView' +export * from './useDataViewFetchAllData' export * from './useDataViewKey' export * from './useDataViewFilter' diff --git a/packages/react-dataview/src/hooks/useDataView.ts b/packages/react-dataview/src/hooks/useDataView.ts index ed321a52e..cbb90464c 100644 --- a/packages/react-dataview/src/hooks/useDataView.ts +++ b/packages/react-dataview/src/hooks/useDataView.ts @@ -45,6 +45,7 @@ export const useDataView = (args: UseDataViewArgs): UseDataViewResult => { dataViewKey: key, filterTypes: args.filterTypes, initialFilters: args.initialFilters, + filteringStateStorage: args.filteringStateStorage, entities, resetPage, }) @@ -52,12 +53,15 @@ export const useDataView = (args: UseDataViewArgs): UseDataViewResult => { const { state: sortingState, methods: sortingMethods } = useDataViewSorting({ dataViewKey: key, initialSorting: args.initialSorting, + sortingStateStorage: args.sortingStateStorage, resetPage, }) const { state: pagingState, methods: pagingMethods, info: pagingInfo } = useDataViewPaging({ dataViewKey: key, initialItemsPerPage: args.initialItemsPerPage, + currentPageStateStorage: args.currentPageStateStorage, + pagingSettingsStorage: args.pagingSettingsStorage, entities, filter: filteringState.filter, }) @@ -67,6 +71,7 @@ export const useDataView = (args: UseDataViewArgs): UseDataViewResult => { resetPage, selectionFallback: args.selectionFallback, initialSelection: args.initialSelection, + selectionStateStorage: args.selectionStateStorage, }) resetPageRef.current = () => { diff --git a/packages/react-dataview/src/hooks/useDataViewFetchAllData.tsx b/packages/react-dataview/src/hooks/useDataViewFetchAllData.tsx new file mode 100644 index 000000000..1a6f47b31 --- /dev/null +++ b/packages/react-dataview/src/hooks/useDataViewFetchAllData.tsx @@ -0,0 +1,41 @@ +import { ReactNode, useCallback } from 'react' +import * as React from 'react' +import { useDataViewEntityListProps, useDataViewFilteringState } from '../contexts' +import { ContentClient, useCurrentContentGraphQlClient } from '@contember/react-client' +import { EntityListSubTree, EntityListSubTreeMarker, MarkerTreeGenerator, QueryGenerator, createQueryBuilder, useEnvironment } from '@contember/react-binding' + +export const useDataViewFetchAllData = ({ children }: { children: ReactNode }) => { + const entityName = useDataViewEntityListProps().entityName + const filter = useDataViewFilteringState().filter + const client = useCurrentContentGraphQlClient() + const env = useEnvironment() + + return useCallback(async () => { + const entities = { + entityName, + filter, + } + const node = ( + + {children} + + ) + + const gen = new MarkerTreeGenerator(node, env) + const qb = createQueryBuilder(env.getSchema()) + const markerTree = gen.generate() + + const queryGenerator = new QueryGenerator(markerTree, qb) + const query = queryGenerator.getReadQuery() + const contentClient = new ContentClient(client) + const marker = Array.from(markerTree.subTrees.values())[0] + if (!(marker instanceof EntityListSubTreeMarker)) { + throw new Error() + } + return { + data: (await contentClient.query(query))[marker.placeholderName], + marker, + } + + }, [client, children, entityName, env, filter]) +} diff --git a/packages/react-dataview/src/index.ts b/packages/react-dataview/src/index.ts index 7ff1e3fda..01db3f71a 100644 --- a/packages/react-dataview/src/index.ts +++ b/packages/react-dataview/src/index.ts @@ -1,5 +1,6 @@ export * from './components' export * from './filterTypes' +export * from './export' export * from './hooks' export * from './types' export * from './contexts' diff --git a/packages/react-dataview/src/internal/hooks/useDataViewFiltering.ts b/packages/react-dataview/src/internal/hooks/useDataViewFiltering.ts index cc538db1b..04b30b894 100644 --- a/packages/react-dataview/src/internal/hooks/useDataViewFiltering.ts +++ b/packages/react-dataview/src/internal/hooks/useDataViewFiltering.ts @@ -1,5 +1,5 @@ import { useCallback, useMemo } from 'react' -import { useSessionStorageState } from '@contember/react-utils' +import { useSessionStorageState, useStoredState } from '@contember/react-utils' import { DataViewFilteringArtifacts, DataViewFilteringMethods, DataViewFilteringProps, DataViewFilteringState } from '../../types' import { useDataViewResolvedFilters } from './useDataViewResolvedFilters' import { QualifiedEntityList } from '@contember/binding' @@ -19,10 +19,14 @@ export type UseDataViewFilteringResult = { } const emptyObject = {} -export const useDataViewFiltering = ({ dataViewKey, initialFilters, filterTypes = emptyObject, resetPage, entities }: UseDataViewFilteringArgs): UseDataViewFilteringResult => { - const [filters, setFilters] = useSessionStorageState(`${dataViewKey}-filters`, val => { - return typeof initialFilters === 'function' ? initialFilters(val ?? {}) : val ?? initialFilters ?? {} - }) +export const useDataViewFiltering = ({ dataViewKey, initialFilters, filteringStateStorage, filterTypes = emptyObject, resetPage, entities }: UseDataViewFilteringArgs): UseDataViewFilteringResult => { + const [filters, setFilters] = useStoredState( + filteringStateStorage ?? 'session', + [dataViewKey ?? 'dataview', 'filters'], + val => { + return typeof initialFilters === 'function' ? initialFilters(val ?? {}) : val ?? initialFilters ?? {} + }, + ) const resolvedFilters = useDataViewResolvedFilters({ entities, filterTypes, diff --git a/packages/react-dataview/src/internal/hooks/useDataViewPaging.ts b/packages/react-dataview/src/internal/hooks/useDataViewPaging.ts index c34c55fab..25dca5d7e 100644 --- a/packages/react-dataview/src/internal/hooks/useDataViewPaging.ts +++ b/packages/react-dataview/src/internal/hooks/useDataViewPaging.ts @@ -1,5 +1,5 @@ import { useEffect, useMemo, useState } from 'react' -import { useSessionStorageState } from '@contember/react-utils' +import { useSessionStorageState, useStoredState } from '@contember/react-utils' import { DataViewPagingInfo, DataViewPagingMethods, DataViewPagingProps, DataViewPagingState } from '../../types' import { useDataViewTotalCount } from './useDataViewTotalCount' import { Filter, QualifiedEntityList } from '@contember/binding' @@ -7,22 +7,41 @@ import { Filter, QualifiedEntityList } from '@contember/binding' type UseDataViewPagingArgs = & { dataViewKey?: string - filter: Filter + filter: Filter entities: QualifiedEntityList } & DataViewPagingProps export const DATA_VIEW_DEFAULT_ITEMS_PER_PAGE = 50 -export const useDataViewPaging = ({ dataViewKey, initialItemsPerPage, ...args }: UseDataViewPagingArgs): { +export const useDataViewPaging = ({ dataViewKey, initialItemsPerPage, pagingSettingsStorage, currentPageStateStorage, ...args }: UseDataViewPagingArgs): { state: DataViewPagingState info: DataViewPagingInfo methods: DataViewPagingMethods } => { - const [pagingState, setPagingState] = useSessionStorageState(`${dataViewKey}-page`, val => val ?? { - itemsPerPage: initialItemsPerPage ?? DATA_VIEW_DEFAULT_ITEMS_PER_PAGE, - pageIndex: 0, - }) + const [currentPageState, setCurrentPageState] = useStoredState>( + currentPageStateStorage ?? 'session', + [dataViewKey ?? 'dataview', 'currentPage'], + val => val ?? { + pageIndex: 0, + }, + ) + const [pagingSettingsState, setPagingSettingsState] = useStoredState>( + pagingSettingsStorage ?? 'local', + [dataViewKey ?? 'dataview', 'itemsPerPage'], + val => val ?? { + itemsPerPage: initialItemsPerPage ?? DATA_VIEW_DEFAULT_ITEMS_PER_PAGE, + }, + ) + + + const pagingState = useMemo(() => { + return { + ...pagingSettingsState, + ...currentPageState, + } + }, [pagingSettingsState, currentPageState]) + const [pagingInfo, setPagingInfo] = useState({ pagesCount: undefined, totalCount: undefined, @@ -44,7 +63,7 @@ export const useDataViewPaging = ({ dataViewKey, initialItemsPerPage, ...args }: methods: useMemo(() => { return { setItemsPerPage: (newItemsPerPage: number | null) => { - setPagingState(val => { + setPagingSettingsState(val => { if (val.itemsPerPage === newItemsPerPage) { return val } @@ -56,7 +75,7 @@ export const useDataViewPaging = ({ dataViewKey, initialItemsPerPage, ...args }: }, goToPage: (page: number | 'first' | 'next' | 'previous' | 'last') => { - setPagingState(val => { + setCurrentPageState(val => { const newPage = (() => { const current = val.pageIndex switch (page) { @@ -86,6 +105,6 @@ export const useDataViewPaging = ({ dataViewKey, initialItemsPerPage, ...args }: }) }, } - }, [pagesCount, setPagingState]), + }, [pagesCount, setCurrentPageState, setPagingSettingsState]), } } diff --git a/packages/react-dataview/src/internal/hooks/useDataViewResolvedFilters.ts b/packages/react-dataview/src/internal/hooks/useDataViewResolvedFilters.ts index 655e67ee6..b58188215 100644 --- a/packages/react-dataview/src/internal/hooks/useDataViewResolvedFilters.ts +++ b/packages/react-dataview/src/internal/hooks/useDataViewResolvedFilters.ts @@ -2,6 +2,7 @@ import { Filter, QualifiedEntityList } from '@contember/binding' import { useMemo } from 'react' import { useEnvironment } from '@contember/react-binding' import { DataViewFilterHandlerRegistry, DataViewFilteringArtifacts } from '../../types' +import { replaceGraphQlLiteral } from '@contember/client' export type UseDataViewResolvedFiltersArgs = { filters: DataViewFilteringArtifacts @@ -31,7 +32,12 @@ export const useDataViewResolvedFilters = ({ return ands }, [environment, filters, filterTypes]) - return useMemo((): Filter => { - return { and: [...customFilters, entities.filter ?? {}] } + return useMemo((): Filter => { + return resolveFilter({ and: [...customFilters, entities.filter ?? {}] }) }, [entities.filter, customFilters]) } + + +const resolveFilter = (input?: Filter): Filter => { + return replaceGraphQlLiteral(input) as Filter +} diff --git a/packages/react-dataview/src/internal/hooks/useDataViewSelection.ts b/packages/react-dataview/src/internal/hooks/useDataViewSelection.ts index 0583a3acb..c5f1d0528 100644 --- a/packages/react-dataview/src/internal/hooks/useDataViewSelection.ts +++ b/packages/react-dataview/src/internal/hooks/useDataViewSelection.ts @@ -1,6 +1,6 @@ import { DataViewSelectionMethods, DataViewSelectionProps, DataViewSelectionState } from '../../types' import { useCallback, useMemo } from 'react' -import { useSessionStorageState } from '@contember/react-utils' +import { useStoredState } from '@contember/react-utils' export type UseDataViewSelectionArgs = & { @@ -14,9 +14,10 @@ export type UseDataViewSelectionResult = { methods: DataViewSelectionMethods } -export const useDataViewSelection = ({ dataViewKey, initialSelection, selectionFallback, resetPage }: UseDataViewSelectionArgs): UseDataViewSelectionResult => { - const [values, setValues] = useSessionStorageState( - `${dataViewKey}-selection`, +export const useDataViewSelection = ({ dataViewKey, initialSelection, selectionStateStorage, selectionFallback, resetPage }: UseDataViewSelectionArgs): UseDataViewSelectionResult => { + const [values, setValues] = useStoredState( + selectionStateStorage ?? 'local', + [dataViewKey ?? 'dataview', 'selection'], val => { return typeof initialSelection === 'function' ? initialSelection(val ?? {}) : val ?? initialSelection ?? {} }, diff --git a/packages/react-dataview/src/internal/hooks/useDataViewSorting.ts b/packages/react-dataview/src/internal/hooks/useDataViewSorting.ts index d6a80a557..296537abc 100644 --- a/packages/react-dataview/src/internal/hooks/useDataViewSorting.ts +++ b/packages/react-dataview/src/internal/hooks/useDataViewSorting.ts @@ -1,5 +1,5 @@ import { useCallback, useMemo } from 'react' -import { useSessionStorageState } from '@contember/react-utils' +import { useStoredState } from '@contember/react-utils' import { DataViewSortingMethods, DataViewSortingProps, DataViewSortingState } from '../../types' import { cycleOrderDirection } from '../helpers/cycleOrderDirection' import { OrderBy, QueryLanguage, useEnvironment } from '@contember/react-binding' @@ -16,9 +16,10 @@ export type UseDataViewSortingResult = { methods: DataViewSortingMethods } -export const useDataViewSorting = ({ dataViewKey, initialSorting, resetPage }: UseDataViewSortingArgs): UseDataViewSortingResult => { - const [directions, setDirections] = useSessionStorageState( - `${dataViewKey}-orderBy`, +export const useDataViewSorting = ({ dataViewKey, initialSorting, sortingStateStorage, resetPage }: UseDataViewSortingArgs): UseDataViewSortingResult => { + const [directions, setDirections] = useStoredState( + sortingStateStorage ?? 'session', + [dataViewKey ?? 'dataview', 'orderBy'], val => val ?? initialSorting ?? {}, ) const environment = useEnvironment() diff --git a/packages/react-dataview/src/internal/hooks/useDataViewTotalCount.ts b/packages/react-dataview/src/internal/hooks/useDataViewTotalCount.ts index 0e8baa001..2cc5bc614 100644 --- a/packages/react-dataview/src/internal/hooks/useDataViewTotalCount.ts +++ b/packages/react-dataview/src/internal/hooks/useDataViewTotalCount.ts @@ -7,7 +7,7 @@ import { useAbortController } from '@contember/react-utils' export type UseDataViewTotalCountArgs = & { entities: QualifiedEntityList - filter: Filter + filter: Filter } export const useDataViewTotalCount = ({ entities: { entityName }, filter }: UseDataViewTotalCountArgs): number | undefined => { @@ -24,7 +24,7 @@ export const useDataViewTotalCount = ({ entities: { entityName }, filter }: UseD const contentClient = new ContentClient(client) const qb = createQueryBuilder(schema) const query = qb.count(entityName, { - filter: resolveFilter(filter), + filter, }) try { const result = await contentClient.query(query, { @@ -44,7 +44,3 @@ export const useDataViewTotalCount = ({ entities: { entityName }, filter }: UseD return count } - -const resolveFilter = (input?: Filter): Filter => { - return replaceGraphQlLiteral(input) as Filter -} diff --git a/packages/react-dataview/src/tsconfig.json b/packages/react-dataview/src/tsconfig.json index bddd23721..29957f571 100644 --- a/packages/react-dataview/src/tsconfig.json +++ b/packages/react-dataview/src/tsconfig.json @@ -8,7 +8,8 @@ { "path": "../../binding/src" }, { "path": "../../react-binding/src" }, { "path": "../../react-client/src" }, + { "path": "../../react-multipass-rendering/src" }, { "path": "../../react-utils/src" }, - { "path": "../../utilities/src" }, + { "path": "../../utilities/src" } ] } diff --git a/packages/react-dataview/src/types/export.ts b/packages/react-dataview/src/types/export.ts new file mode 100644 index 000000000..ca01b6e0b --- /dev/null +++ b/packages/react-dataview/src/types/export.ts @@ -0,0 +1,6 @@ +import { EntityListSubTreeMarker, FieldMarker, HasManyRelationMarker, HasOneRelationMarker } from '@contember/binding' + +export type DataViewDataForExport = { + markerPath: (EntityListSubTreeMarker | HasOneRelationMarker | HasManyRelationMarker | FieldMarker)[] + values: any[] +}[] diff --git a/packages/react-dataview/src/types/filtering.ts b/packages/react-dataview/src/types/filtering.ts index dd4ad3548..392ceb0ef 100644 --- a/packages/react-dataview/src/types/filtering.ts +++ b/packages/react-dataview/src/types/filtering.ts @@ -1,4 +1,4 @@ -import { Serializable } from '@contember/react-utils' +import { Serializable, StateStorageOrName } from '@contember/react-utils' import { Environment, SugaredFilter } from '@contember/react-binding' import { Filter } from '@contember/binding' import { SetStateAction } from 'react' @@ -32,11 +32,12 @@ export type DataViewFilterHandlerRegistry = Record export type DataViewFilteringState = { artifact: DataViewFilteringArtifacts - filter: Filter + filter: Filter filterTypes: DataViewFilterHandlerRegistry } export type DataViewFilteringProps = { filterTypes?: DataViewFilterHandlerRegistry initialFilters?: DataViewFilteringArtifacts | ((stored: DataViewFilteringArtifacts) => DataViewFilteringArtifacts) + filteringStateStorage?: StateStorageOrName } diff --git a/packages/react-dataview/src/types/index.ts b/packages/react-dataview/src/types/index.ts index cff8ba0d6..9b0d40b55 100644 --- a/packages/react-dataview/src/types/index.ts +++ b/packages/react-dataview/src/types/index.ts @@ -1,4 +1,5 @@ export * from './dataview' +export * from './export' export * from './filtering' export * from './paging' export * from './sorting' diff --git a/packages/react-dataview/src/types/paging.ts b/packages/react-dataview/src/types/paging.ts index e8ad246fc..d2e5d3040 100644 --- a/packages/react-dataview/src/types/paging.ts +++ b/packages/react-dataview/src/types/paging.ts @@ -1,3 +1,5 @@ +import { StateStorageOrName } from '@contember/react-utils' + export type DataViewPagingMethods = { goToPage: (page: number | 'first' | 'next' | 'previous' | 'last') => void setItemsPerPage: (newItemsPerPage: number | null) => void @@ -15,4 +17,6 @@ export type DataViewPagingInfo = { export interface DataViewPagingProps { initialItemsPerPage?: number | null + currentPageStateStorage?: StateStorageOrName + pagingSettingsStorage?: StateStorageOrName } diff --git a/packages/react-dataview/src/types/selection.ts b/packages/react-dataview/src/types/selection.ts index 2cbd8788e..99185cf67 100644 --- a/packages/react-dataview/src/types/selection.ts +++ b/packages/react-dataview/src/types/selection.ts @@ -1,3 +1,4 @@ +import { StateStorageOrName } from '@contember/react-utils' import { SetStateAction } from 'react' export type DataViewSelectionValue = boolean | string | number | null @@ -17,4 +18,5 @@ export type DataViewSelectionMethods = { export type DataViewSelectionProps = { initialSelection?: DataViewSelectionValues | ((stored: DataViewSelectionValues) => DataViewSelectionValues) selectionFallback?: DataViewSelectionValue + selectionStateStorage?: StateStorageOrName } diff --git a/packages/react-dataview/src/types/sorting.ts b/packages/react-dataview/src/types/sorting.ts index 64b98162f..4dddc79c9 100644 --- a/packages/react-dataview/src/types/sorting.ts +++ b/packages/react-dataview/src/types/sorting.ts @@ -1,5 +1,6 @@ import { Environment, SugaredOrderBy } from '@contember/binding' import { OrderBy } from '@contember/react-binding' +import { StateStorageOrName } from '@contember/react-utils' export type DataViewSortingDirection = 'asc' | 'desc' | null export type DataViewSortingDirectionAction = DataViewSortingDirection | 'next' | 'toggleAsc' | 'toggleDesc' | 'clear' @@ -31,4 +32,5 @@ export type DataViewSortingState = { export type DataViewSortingProps = { initialSorting?: DataViewSortingDirections + sortingStateStorage?: StateStorageOrName } diff --git a/packages/react-select/src/components/MultiSelect.tsx b/packages/react-select/src/components/MultiSelect.tsx index 9b820c605..eee3cba4b 100644 --- a/packages/react-select/src/components/MultiSelect.tsx +++ b/packages/react-select/src/components/MultiSelect.tsx @@ -1,19 +1,23 @@ import React, { ReactNode, useCallback, useMemo } from 'react' -import { SelectCurrentEntitiesContext, SelectHandler, SelectHandleSelectContext, SelectIsSelectedContext, SelectOptionsContext } from '../contexts' +import { SelectCurrentEntitiesContext, SelectHandler, SelectHandleSelectContext, SelectIsSelectedContext, SelectOptionsContext, SelectOptionsFilterContext } from '../contexts' import { Component, EntityAccessor, HasMany, SugaredQualifiedEntityList, SugaredRelativeEntityList, useEntityList } from '@contember/react-binding' import { useReferentiallyStableCallback } from '@contember/react-utils' import { SelectEvents } from '../types' +import { SelectFilterFieldProps, useSelectFilter } from '../hooks' export type MultiSelectProps = & { children: ReactNode field: SugaredRelativeEntityList['field'] - options: SugaredQualifiedEntityList['entities'] + options?: SugaredQualifiedEntityList['entities'] } + & SelectFilterFieldProps & SelectEvents -export const MultiSelect = Component(({ field, children, options, onSelect, onUnselect }: MultiSelectProps) => { +export const MultiSelect = Component(({ field, children, options, onSelect, onUnselect, filterField }: MultiSelectProps) => { const entities = useEntityList({ field }) + options ??= entities.name + const filter = useSelectFilter({ filterField, marker: entities.getMarker() }) const entitiesArr = useMemo(() => Array.from(entities), [entities]) const selectedEntities = useMemo(() => Array.from(entities).map(it => it.id), [entities]) @@ -39,7 +43,9 @@ export const MultiSelect = Component(({ field, children, options, onSelect, onUn - {children} + + {children} + @@ -47,7 +53,7 @@ export const MultiSelect = Component(({ field, children, options, onSelect, onUn ) }, ({ field, children }) => { return ( - + {children} ) diff --git a/packages/react-select/src/components/Select.tsx b/packages/react-select/src/components/Select.tsx index 2f7b73ec3..dc665b6fa 100644 --- a/packages/react-select/src/components/Select.tsx +++ b/packages/react-select/src/components/Select.tsx @@ -1,20 +1,25 @@ import React, { ReactNode, useCallback, useMemo } from 'react' -import { SelectCurrentEntitiesContext, SelectHandler, SelectHandleSelectContext, SelectIsSelectedContext, SelectOptionsContext } from '../contexts' +import { SelectCurrentEntitiesContext, SelectHandler, SelectHandleSelectContext, SelectIsSelectedContext, SelectOptionsContext, SelectOptionsFilterContext } from '../contexts' import { Component, EntityAccessor, HasOne, SugaredQualifiedEntityList, SugaredRelativeSingleEntity, useEntity } from '@contember/react-binding' import { useReferentiallyStableCallback } from '@contember/react-utils' import { SelectEvents } from '../types' +import { SelectFilterFieldProps, useSelectFilter } from '../hooks' export type SelectProps = & { children: ReactNode field: SugaredRelativeSingleEntity['field'] - options: SugaredQualifiedEntityList['entities'] + options?: SugaredQualifiedEntityList['entities'] } + & SelectFilterFieldProps & SelectEvents -export const Select = Component(({ field, children, onUnselect, onSelect, options }: SelectProps) => { +export const Select = Component(({ field, children, onUnselect, onSelect, options, filterField }: SelectProps) => { const entity = useEntity() const selectedEntity = entity.getEntity({ field }) + options ??= selectedEntity.name + const filter = useSelectFilter({ filterField, marker: selectedEntity.getMarker() }) + const entityExists = selectedEntity.existsOnServer || selectedEntity.hasUnpersistedChanges const entitiesArr = useMemo(() => entityExists ? [selectedEntity] : [], [entityExists, selectedEntity]) @@ -37,7 +42,9 @@ export const Select = Component(({ field, children, onUnselect, onSelect, option - {children} + + {children} + @@ -45,7 +52,7 @@ export const Select = Component(({ field, children, onUnselect, onSelect, option ) }, ({ field, children }) => { return ( - + {children} ) diff --git a/packages/react-select/src/components/SelectDataView.tsx b/packages/react-select/src/components/SelectDataView.tsx index d8c9941aa..cca98755c 100644 --- a/packages/react-select/src/components/SelectDataView.tsx +++ b/packages/react-select/src/components/SelectDataView.tsx @@ -1,6 +1,6 @@ -import React, { ReactNode } from 'react' -import { useSelectHandleSelect, useSelectOptions } from '../contexts' -import { DataViewProps, DataView } from '@contember/react-dataview' +import React, { ReactNode, useMemo } from 'react' +import { useSelectHandleSelect, useSelectOptions, useSelectOptionsFilter } from '../contexts' +import { DataViewProps, DataView, DataViewFilterHandlerRegistry } from '@contember/react-dataview' export type SelectDataViewProps = & Omit @@ -11,7 +11,18 @@ export type SelectDataViewProps = export const SelectDataView = (props: SelectDataViewProps) => { const handleSelect = useSelectHandleSelect() const entities = useSelectOptions() + const filter = useSelectOptionsFilter() + const defaultFilterTypes = useMemo((): DataViewFilterHandlerRegistry => filter ? { query: filter } : {}, [filter]) + return ( - + ) } diff --git a/packages/react-select/src/components/SortableMultiSelect.tsx b/packages/react-select/src/components/SortableMultiSelect.tsx index 0a5b2b29c..910064099 100644 --- a/packages/react-select/src/components/SortableMultiSelect.tsx +++ b/packages/react-select/src/components/SortableMultiSelect.tsx @@ -1,24 +1,57 @@ import React, { ReactNode, useCallback, useMemo } from 'react' -import { SelectCurrentEntitiesContext, SelectHandler, SelectHandleSelectContext, SelectIsSelectedContext, SelectOptionsContext } from '../contexts' -import { Component, EntityAccessor, HasOne, SugaredQualifiedEntityList, SugaredRelativeEntityList, SugaredRelativeSingleEntity, SugaredRelativeSingleField, useEntityList } from '@contember/react-binding' +import { SelectCurrentEntitiesContext, SelectHandler, SelectHandleSelectContext, SelectIsSelectedContext, SelectOptionsContext, SelectOptionsFilterContext } from '../contexts' +import { + Component, + EntityAccessor, + FieldMarker, + HasOne, + MeaningfulMarker, + PlaceholderGenerator, + QueryLanguage, + SugaredQualifiedEntityList, + SugaredRelativeEntityList, + SugaredRelativeSingleEntity, + SugaredRelativeSingleField, + useEntityList, +} from '@contember/react-binding' import { useReferentiallyStableCallback } from '@contember/react-utils' import { Repeater } from '@contember/react-repeater' import { SelectEvents } from '../types' +import { SelectFilterFieldProps, useSelectFilter } from '../hooks' export type SortableMultiSelectProps = & { children: ReactNode field: SugaredRelativeEntityList['field'] - options: SugaredQualifiedEntityList['entities'] + options?: SugaredQualifiedEntityList['entities'] sortableBy: SugaredRelativeSingleField['field'] connectAt: SugaredRelativeSingleEntity['field'] } + & SelectFilterFieldProps & SelectEvents -export const SortableMultiSelect = Component(({ field, children, sortableBy, connectAt, options, onSelect, onUnselect }: SortableMultiSelectProps) => { +export const SortableMultiSelect = Component(({ field, children, sortableBy, connectAt, options, onSelect, onUnselect, filterField }: SortableMultiSelectProps) => { const list = useEntityList({ field }) const entitiesArr = useMemo(() => Array.from(list), [list]) + const optionsMarker = useMemo(() => { + const desugared = QueryLanguage.desugarRelativeSingleEntity({ field: connectAt }, list.environment) + let marker: Exclude = list.getMarker() + for (let relation of desugared.hasOneRelationPath) { + const placeholder = PlaceholderGenerator.getHasOneRelationPlaceholder(relation) + const relationMarker = marker.fields.markers.get(placeholder) + if (!relationMarker || relationMarker instanceof FieldMarker) { + throw new Error('Invalid marker') + } + marker = relationMarker + } + return marker + }, [connectAt, list]) + + options ??= optionsMarker.environment.getSubTreeNode().entity.name + + const filter = useSelectFilter({ filterField, marker: optionsMarker }) + const selectedEntities = useMemo(() => Array.from(list).map(it => it.getEntity({ field: connectAt }).id), [connectAt, list]) const handler = useReferentiallyStableCallback((entity, action = 'toggle') => { @@ -46,7 +79,9 @@ export const SortableMultiSelect = Component(({ field, children, sortableBy, con - {children} + + {children} + @@ -56,7 +91,7 @@ export const SortableMultiSelect = Component(({ field, children, sortableBy, con }, ({ children, field, sortableBy, connectAt }) => { return ( - + {children} diff --git a/packages/react-select/src/contexts.ts b/packages/react-select/src/contexts.ts index a924a1e6c..7c083aea3 100644 --- a/packages/react-select/src/contexts.ts +++ b/packages/react-select/src/contexts.ts @@ -1,5 +1,6 @@ import { createRequiredContext } from '@contember/react-utils' import { EntityAccessor, SugaredQualifiedEntityList } from '@contember/react-binding' +import { DataViewFilterHandler, TextFilterArtifacts } from '@contember/react-dataview' const _SelectCurrentEntitiesContext = createRequiredContext('SelectCurrentEntitiesContext') /** @internal */ @@ -23,3 +24,8 @@ const _SelectOptionsContext = createRequiredContext | undefined>('SelectOptionsFilterContext') +/** @internal */ +export const SelectOptionsFilterContext = _SelectOptionsFilterContext[0] +export const useSelectOptionsFilter = _SelectOptionsFilterContext[1] diff --git a/packages/react-select/src/hooks/index.ts b/packages/react-select/src/hooks/index.ts new file mode 100644 index 000000000..575e7ab24 --- /dev/null +++ b/packages/react-select/src/hooks/index.ts @@ -0,0 +1 @@ +export * from './useSelectFilter' diff --git a/packages/react-select/src/hooks/useSelectFilter.ts b/packages/react-select/src/hooks/useSelectFilter.ts new file mode 100644 index 000000000..b079a60a9 --- /dev/null +++ b/packages/react-select/src/hooks/useSelectFilter.ts @@ -0,0 +1,35 @@ +import { FieldMarker, HasOneRelationMarker, MeaningfulMarker, SugaredRelativeSingleField } from '@contember/react-binding' +import { createUnionTextFilter } from '@contember/react-dataview' +import { useMemo } from 'react' + +export type SelectFilterFieldProps = { + filterField?: SugaredRelativeSingleField['field'] | SugaredRelativeSingleField['field'][] +} + +export const useSelectFilter = ({ filterField, marker }: SelectFilterFieldProps & { + marker: Exclude +}) => { + return useMemo(() => { + const filter = filterField ?? extractStringFields(marker) + if (!filter || (Array.isArray(filter) && filter.length === 0)) { + return undefined + } + return createUnionTextFilter(Array.isArray(filter) ? filter : [filter]) + }, [filterField, marker]) +} + +const extractStringFields = (marker: Exclude): string[] => { + const node = marker.environment.getSubTreeNode() + const textFields = [] + for (const field of marker.fields.markers.values()) { + if (field instanceof FieldMarker) { + const columnInfo = node.entity.fields.get(field.fieldName) + if (columnInfo?.type === 'String') { + textFields.push(field.fieldName) + } + } else if (field instanceof HasOneRelationMarker) { + textFields.push(...extractStringFields(field).map(it => `${field.parameters.field}.${it}`)) + } + } + return textFields +} diff --git a/packages/react-select/src/index.ts b/packages/react-select/src/index.ts index 759a92258..59faaf52d 100644 --- a/packages/react-select/src/index.ts +++ b/packages/react-select/src/index.ts @@ -1,3 +1,4 @@ export * from './components' export * from './contexts' +export * from './hooks' export * from './types' diff --git a/packages/react-uploader/src/index.ts b/packages/react-uploader/src/index.ts index d9a1ea05e..58e21f0ad 100644 --- a/packages/react-uploader/src/index.ts +++ b/packages/react-uploader/src/index.ts @@ -4,6 +4,7 @@ export * from './extractors' export * from './fileTypes' export * from './types' export * from './hooks' +export * from './uploadClient' export * from './UploaderError' diff --git a/packages/react-uploader/src/types/index.ts b/packages/react-uploader/src/types/index.ts index 4c6eaeee3..8cea76a5f 100644 --- a/packages/react-uploader/src/types/index.ts +++ b/packages/react-uploader/src/types/index.ts @@ -2,6 +2,7 @@ export * from './base' export * from './events' export * from './extractor' export * from './file' +export * from './options' export * from './type' export * from './state' export * from './uploadHandler' diff --git a/packages/react-uploader/src/uploadClient/S3UploadClient.ts b/packages/react-uploader/src/uploadClient/S3UploadClient.ts index 67e849ba0..eb9882f1e 100644 --- a/packages/react-uploader/src/uploadClient/S3UploadClient.ts +++ b/packages/react-uploader/src/uploadClient/S3UploadClient.ts @@ -3,7 +3,7 @@ import { GenerateUploadUrlMutationBuilder } from '@contember/client' import { GraphQlClient } from '@contember/graphql-client' import { UploaderError } from '../UploaderError' -interface S3UploadClientOptions { +export interface S3UploadClientOptions { getUploadOptions?: (file: File) => S3FileOptions concurrency?: number } diff --git a/packages/react-uploader/src/uploadClient/index.ts b/packages/react-uploader/src/uploadClient/index.ts new file mode 100644 index 000000000..5424b9b8f --- /dev/null +++ b/packages/react-uploader/src/uploadClient/index.ts @@ -0,0 +1,2 @@ +export * from './S3UploadClient' +export * from './UploadClient' diff --git a/packages/react-utils/src/hooks/useStoredState.ts b/packages/react-utils/src/hooks/useStoredState.ts index fff4406d4..c38c55fa3 100644 --- a/packages/react-utils/src/hooks/useStoredState.ts +++ b/packages/react-utils/src/hooks/useStoredState.ts @@ -1,26 +1,112 @@ -import { useCallback, useState } from 'react' +import { useCallback, useMemo, useState } from 'react' import { Serializable } from '../types' export type SetState = (value: V | ((current: V) => V)) => void; +export type StateStorageKey = [uniquePrefix: string, key: string] + export type ValueInitializer = (storedValue: V | undefined) => V; -const createStoredStateHook = (getStorage: () => Storage) => { - return (key: string, initializeValue: ValueInitializer): [V, SetState] => { - const storage = getStorage() - const [value, setValue] = useState(() => { - const value = storage.getItem(key) - const parsedValue: V | undefined = value !== null ? JSON.parse(value) : undefined - return initializeValue(parsedValue) - }) - return [value, useCallback(value => { +export interface StateStorage { + getItem(key: StateStorageKey): Serializable + + setItem(key: StateStorageKey, value: Serializable): void +} + + +export const urlStateStorage: StateStorage = { + getItem(key) { + const searchParams = new URLSearchParams(window.location.search) + const value = searchParams.get(key[1]) + return value !== null && value !== undefined ? JSON.parse(value) : null + }, + setItem(key, value) { + const searchParams = new URLSearchParams(window.location.search) + searchParams.set(key[1], JSON.stringify(value)) + const newUrl = `${window.location.pathname}?${searchParams.toString()}` + window.history.replaceState({}, '', newUrl) + }, +} + +const createStateStorage = (getStorage: () => Storage): StateStorage => { + return { + getItem(key) { + const value = getStorage().getItem(key.join('-')) + return value !== null ? JSON.parse(value) : null + }, + setItem(key, value) { + getStorage().setItem(key.join('-'), JSON.stringify(value)) + }, + } +} + +export const sessionStateStorage = createStateStorage(() => sessionStorage) +export const localStateStorage = createStateStorage(() => localStorage) +export const nullStorage: StateStorage = { + getItem() { + return null + }, + setItem() { + }, +} + +const builtInStorages = { + url: urlStateStorage, + session: sessionStateStorage, + local: localStateStorage, + null: nullStorage, +} + +export type StateStorageOrName = StateStorage | 'url' | 'session' | 'local' | 'null' + +export const useStoredState = (storageOrName: StateStorageOrName | StateStorageOrName[], key: StateStorageKey, initializeValue: ValueInitializer): [V, SetState] => { + const storage = useMemo(() => { + if (Array.isArray(storageOrName)) { + return createFallbackStorage(storageOrName) + } + return typeof storageOrName === 'string' ? builtInStorages[storageOrName] : storageOrName + }, [storageOrName]) + + const [value, setValue] = useState(() => { + const value = storage.getItem(key) + return initializeValue(value as V) + }) + return [ + value, + useCallback(value => { setValue(current => { const newValue = typeof value === 'function' ? (value as (current: V) => V)(current) : value - storage.setItem(key, JSON.stringify(newValue)) + storage.setItem(key, newValue) return newValue }) - }, [key, storage])] - } + }, [key, storage]), + ] +} +export const useSessionStorageState = (key: StateStorageKey, initializeValue: ValueInitializer): [V, SetState] => { + return useStoredState(sessionStateStorage, key, initializeValue) } -export const useSessionStorageState = createStoredStateHook(() => sessionStorage) +export const useLocalStorageState = (key: StateStorageKey, initializeValue: ValueInitializer): [V, SetState] => { + return useStoredState(localStateStorage, key, initializeValue) +} + + +const createFallbackStorage = (storageTypes: StateStorageOrName[]): StateStorage => { + const resultStorages = storageTypes.map(it => typeof it === 'string' ? builtInStorages[it] : it) + return { + getItem(key) { + for (const storage of resultStorages) { + const value = storage.getItem(key) + if (value !== null) { + return value + } + } + return null + }, + setItem(key, value) { + for (const storage of resultStorages) { + storage.setItem(key, value) + } + }, + } +} diff --git a/packages/ui/src/components/Menu/MenuItem.tsx b/packages/ui/src/components/Menu/MenuItem.tsx index dabaddb51..046d6dfd4 100644 --- a/packages/ui/src/components/Menu/MenuItem.tsx +++ b/packages/ui/src/components/Menu/MenuItem.tsx @@ -24,7 +24,7 @@ export function MenuItem({ children, componentClassName = 'menu-ite const menuItemId = `${componentClassName}-${depth}-${href ?? label}` const menuId = useMenuId() const [expanded, setExpanded] = useSessionStorageState( - `menu-${menuId}-${menuItemId}`, + ['', `menu-${menuId}-${menuItemId}`], val => val ?? props.expandedByDefault ?? (depth === 0 || !label), ) diff --git a/yarn.lock b/yarn.lock index 12fc6c4df..b678c705d 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1515,6 +1515,7 @@ __metadata: version: 0.0.0-use.local resolution: "@contember/react-datagrid@workspace:packages/react-datagrid" dependencies: + "@contember/client": "workspace:*" "@contember/react-binding": "workspace:*" "@contember/react-choice-field": "workspace:*" "@contember/react-dataview": "workspace:*" @@ -1536,6 +1537,7 @@ __metadata: "@contember/client": "workspace:*" "@contember/react-binding": "workspace:*" "@contember/react-client": "workspace:*" + "@contember/react-multipass-rendering": "workspace:*" "@contember/react-utils": "workspace:*" "@contember/utilities": "workspace:*" "@radix-ui/react-slot": ^1.0.2