@contember/bindx-repeater 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,46 @@
1
+ import { type ReactElement } from 'react';
2
+ import type { AnyBrand } from '@contember/bindx';
3
+ import { BINDX_COMPONENT, type SelectionProvider } from '@contember/bindx-react';
4
+ import type { BlockRepeaterProps } from '../types.js';
5
+ /**
6
+ * Block repeater component for has-many relations with type discrimination.
7
+ *
8
+ * Each item has a discrimination field that determines its block type,
9
+ * allowing different rendering based on the block type.
10
+ *
11
+ * @example
12
+ * ```tsx
13
+ * <BlockRepeater
14
+ * field={entity.blocks}
15
+ * discriminationField="type"
16
+ * sortableBy="order"
17
+ * blocks={{
18
+ * text: { label: 'Text' },
19
+ * image: { label: 'Image' },
20
+ * }}
21
+ * >
22
+ * {(items, methods) => (
23
+ * <>
24
+ * {items.map((item, info) => {
25
+ * switch (info.blockType) {
26
+ * case 'text': return <div key={item.id}>{item.content.value}</div>
27
+ * case 'image': return <img key={item.id} src={item.url.value} />
28
+ * default: return null
29
+ * }
30
+ * })}
31
+ * {methods.blockList.map(b => (
32
+ * <button key={b.name} onClick={() => methods.addItem(b.name)}>
33
+ * Add {b.label ?? b.name}
34
+ * </button>
35
+ * ))}
36
+ * </>
37
+ * )}
38
+ * </BlockRepeater>
39
+ * ```
40
+ */
41
+ export declare function BlockRepeater<TEntity extends object = object, TSelected = TEntity, TBrand extends AnyBrand = AnyBrand, TEntityName extends string = string, TSchema extends Record<string, object> = Record<string, object>, TBlockNames extends string = string>({ field, discriminationField, sortableBy, blocks, children, }: BlockRepeaterProps<TEntity, TSelected, TBrand, TEntityName, TSchema, TBlockNames>): ReactElement;
42
+ declare const blockRepeaterWithSelection: typeof BlockRepeater & SelectionProvider & {
43
+ [BINDX_COMPONENT]: true;
44
+ };
45
+ export { blockRepeaterWithSelection as BlockRepeaterWithMeta };
46
+ //# sourceMappingURL=BlockRepeater.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"BlockRepeater.d.ts","sourceRoot":"","sources":["../../src/components/BlockRepeater.tsx"],"names":[],"mappings":"AAAA,OAAc,EAAW,KAAK,YAAY,EAAkB,MAAM,OAAO,CAAA;AACzE,OAAO,KAAK,EAGX,QAAQ,EAIR,MAAM,kBAAkB,CAAA;AAEzB,OAAO,EAAyC,eAAe,EAAE,KAAK,iBAAiB,EAAE,MAAM,wBAAwB,CAAA;AACvH,OAAO,KAAK,EACX,kBAAkB,EAMlB,MAAM,aAAa,CAAA;AAMpB;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GAmCG;AACH,wBAAgB,aAAa,CAC5B,OAAO,SAAS,MAAM,GAAG,MAAM,EAC/B,SAAS,GAAG,OAAO,EACnB,MAAM,SAAS,QAAQ,GAAG,QAAQ,EAClC,WAAW,SAAS,MAAM,GAAG,MAAM,EACnC,OAAO,SAAS,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,GAAG,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,EAC/D,WAAW,SAAS,MAAM,GAAG,MAAM,EAClC,EACD,KAAK,EACL,mBAAmB,EACnB,UAAU,EACV,MAAM,EACN,QAAQ,GACR,EAAE,kBAAkB,CAAC,OAAO,EAAE,SAAS,EAAE,MAAM,EAAE,WAAW,EAAE,OAAO,EAAE,WAAW,CAAC,GAAG,YAAY,CA+HlG;AAGD,QAAA,MAAM,0BAA0B,EAAoB,OAAO,aAAa,GAAG,iBAAiB,GAAG;IAAE,CAAC,eAAe,CAAC,EAAE,IAAI,CAAA;CAAE,CAAA;AA8D1H,OAAO,EAAE,0BAA0B,IAAI,qBAAqB,EAAE,CAAA"}
@@ -0,0 +1,38 @@
1
+ import { type ReactElement } from 'react';
2
+ import type { AnyBrand } from '@contember/bindx';
3
+ import { BINDX_COMPONENT, type SelectionProvider } from '@contember/bindx-react';
4
+ import type { RepeaterProps } from '../types.js';
5
+ /**
6
+ * Main repeater component for rendering has-many relations with full type safety.
7
+ *
8
+ * Uses a callback-style API where types flow from the `field` prop through
9
+ * the `items.map()` callback, ensuring compile-time type safety.
10
+ *
11
+ * @example
12
+ * ```tsx
13
+ * <Repeater field={author.articles} sortableBy="order">
14
+ * {(items, { addItem, isEmpty }) => (
15
+ * <>
16
+ * {isEmpty && <p>No articles</p>}
17
+ *
18
+ * {items.map((article, { index, isFirst, isLast, remove, moveUp, moveDown }) => (
19
+ * <div key={article.id}>
20
+ * {article.title.value}
21
+ * <button onClick={remove}>Remove</button>
22
+ * <button onClick={moveUp} disabled={isFirst}>↑</button>
23
+ * <button onClick={moveDown} disabled={isLast}>↓</button>
24
+ * </div>
25
+ * ))}
26
+ *
27
+ * <button onClick={() => addItem()}>Add</button>
28
+ * </>
29
+ * )}
30
+ * </Repeater>
31
+ * ```
32
+ */
33
+ export declare function Repeater<TEntity extends object = object, TSelected = TEntity, TBrand extends AnyBrand = AnyBrand, TEntityName extends string = string, TSchema extends Record<string, object> = Record<string, object>>({ field, sortableBy, children, }: RepeaterProps<TEntity, TSelected, TBrand, TEntityName, TSchema>): ReactElement;
34
+ declare const repeaterWithSelection: typeof Repeater & SelectionProvider & {
35
+ [BINDX_COMPONENT]: true;
36
+ };
37
+ export { repeaterWithSelection as RepeaterWithMeta };
38
+ //# sourceMappingURL=Repeater.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"Repeater.d.ts","sourceRoot":"","sources":["../../src/components/Repeater.tsx"],"names":[],"mappings":"AAAA,OAAc,EAAW,KAAK,YAAY,EAAkB,MAAM,OAAO,CAAA;AACzE,OAAO,KAAK,EAGX,QAAQ,EAGR,MAAM,kBAAkB,CAAA;AAEzB,OAAO,EAAyC,eAAe,EAAE,KAAK,iBAAiB,EAAE,MAAM,wBAAwB,CAAA;AACvH,OAAO,KAAK,EACX,aAAa,EAMb,MAAM,aAAa,CAAA;AAMpB;;;;;;;;;;;;;;;;;;;;;;;;;;;GA2BG;AACH,wBAAgB,QAAQ,CACvB,OAAO,SAAS,MAAM,GAAG,MAAM,EAC/B,SAAS,GAAG,OAAO,EACnB,MAAM,SAAS,QAAQ,GAAG,QAAQ,EAClC,WAAW,SAAS,MAAM,GAAG,MAAM,EACnC,OAAO,SAAS,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,GAAG,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,EAC9D,EACD,KAAK,EACL,UAAU,EACV,QAAQ,GACR,EAAE,aAAa,CAAC,OAAO,EAAE,SAAS,EAAE,MAAM,EAAE,WAAW,EAAE,OAAO,CAAC,GAAG,YAAY,CAuHhF;AAGD,QAAA,MAAM,qBAAqB,EAAe,OAAO,QAAQ,GAAG,iBAAiB,GAAG;IAAE,CAAC,eAAe,CAAC,EAAE,IAAI,CAAA;CAAE,CAAA;AAyD3G,OAAO,EAAE,qBAAqB,IAAI,gBAAgB,EAAE,CAAA"}
@@ -0,0 +1,3 @@
1
+ export { Repeater, RepeaterWithMeta } from './Repeater.js';
2
+ export { BlockRepeater, BlockRepeaterWithMeta } from './BlockRepeater.js';
3
+ //# sourceMappingURL=index.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../src/components/index.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,QAAQ,EAAE,gBAAgB,EAAE,MAAM,eAAe,CAAA;AAC1D,OAAO,EAAE,aAAa,EAAE,qBAAqB,EAAE,MAAM,oBAAoB,CAAA"}
@@ -0,0 +1,2 @@
1
+ export { useSortedItems } from './useSortedItems.js';
2
+ //# sourceMappingURL=index.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../src/hooks/index.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,cAAc,EAAE,MAAM,qBAAqB,CAAA"}
@@ -0,0 +1,10 @@
1
+ import type { EntityAccessor, HasManyRef, AnyBrand } from '@contember/bindx';
2
+ /**
3
+ * Hook that returns sorted items from a has-many ref and repairs order field values.
4
+ *
5
+ * @param hasMany - The has-many ref
6
+ * @param orderField - Optional field name for sorting
7
+ * @returns Sorted array of entity accessors
8
+ */
9
+ export declare function useSortedItems<T extends object, S = T, TBrand extends AnyBrand = AnyBrand, TEntityName extends string = string, TSchema extends Record<string, object> = Record<string, object>>(hasMany: HasManyRef<T, S, TBrand, TEntityName, TSchema>, orderField: string | undefined): EntityAccessor<T, S, TBrand, TEntityName, TSchema>[];
10
+ //# sourceMappingURL=useSortedItems.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"useSortedItems.d.ts","sourceRoot":"","sources":["../../src/hooks/useSortedItems.ts"],"names":[],"mappings":"AACA,OAAO,KAAK,EAAE,cAAc,EAAE,UAAU,EAAE,QAAQ,EAAE,MAAM,kBAAkB,CAAA;AAI5E;;;;;;GAMG;AACH,wBAAgB,cAAc,CAC7B,CAAC,SAAS,MAAM,EAChB,CAAC,GAAG,CAAC,EACL,MAAM,SAAS,QAAQ,GAAG,QAAQ,EAClC,WAAW,SAAS,MAAM,GAAG,MAAM,EACnC,OAAO,SAAS,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,GAAG,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,EAE/D,OAAO,EAAE,UAAU,CAAC,CAAC,EAAE,CAAC,EAAE,MAAM,EAAE,WAAW,EAAE,OAAO,CAAC,EACvD,UAAU,EAAE,MAAM,GAAG,SAAS,GAC5B,cAAc,CAAC,CAAC,EAAE,CAAC,EAAE,MAAM,EAAE,WAAW,EAAE,OAAO,CAAC,EAAE,CAgBtD"}
@@ -0,0 +1,11 @@
1
+ /**
2
+ * Repeater components for has-many list management with Bindx
3
+ *
4
+ * @packageDocumentation
5
+ */
6
+ export type { RepeaterAddItemIndex, RepeaterMoveItemIndex, RepeaterPreprocessCallback, RepeaterItemInfo, RepeaterItems, RepeaterMethods, RepeaterRenderFn, RepeaterProps, BlockDefinition, BlockRepeaterItemInfo, BlockRepeaterItems, BlockRepeaterMethods, BlockRepeaterRenderFn, BlockRepeaterProps, } from './types.js';
7
+ export { useSortedItems } from './hooks/index.js';
8
+ export { arrayMove, sortEntities, repairEntitiesOrder, } from './utils/index.js';
9
+ export { Repeater, RepeaterWithMeta } from './components/index.js';
10
+ export { BlockRepeater, BlockRepeaterWithMeta } from './components/index.js';
11
+ //# sourceMappingURL=index.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA;;;;GAIG;AAGH,YAAY,EACX,oBAAoB,EACpB,qBAAqB,EACrB,0BAA0B,EAC1B,gBAAgB,EAChB,aAAa,EACb,eAAe,EACf,gBAAgB,EAChB,aAAa,EACb,eAAe,EACf,qBAAqB,EACrB,kBAAkB,EAClB,oBAAoB,EACpB,qBAAqB,EACrB,kBAAkB,GAClB,MAAM,YAAY,CAAA;AAGnB,OAAO,EAAE,cAAc,EAAE,MAAM,kBAAkB,CAAA;AAGjD,OAAO,EACN,SAAS,EACT,YAAY,EACZ,mBAAmB,GACnB,MAAM,kBAAkB,CAAA;AAGzB,OAAO,EAAE,QAAQ,EAAE,gBAAgB,EAAE,MAAM,uBAAuB,CAAA;AAClE,OAAO,EAAE,aAAa,EAAE,qBAAqB,EAAE,MAAM,uBAAuB,CAAA"}
@@ -0,0 +1,141 @@
1
+ import type { ReactNode } from 'react';
2
+ import type { EntityAccessor, HasManyRef, AnyBrand } from '@contember/bindx';
3
+ /**
4
+ * Index for adding items to the repeater.
5
+ * - number: Adds at the specified index
6
+ * - 'first': Adds at the beginning
7
+ * - 'last' or undefined: Adds at the end
8
+ */
9
+ export type RepeaterAddItemIndex = number | 'first' | 'last' | undefined;
10
+ /**
11
+ * Index for moving items within the repeater.
12
+ * - number: Moves to the specified index
13
+ * - 'first': Moves to the beginning
14
+ * - 'last': Moves to the end
15
+ * - 'previous': Moves to the previous position
16
+ * - 'next': Moves to the next position
17
+ */
18
+ export type RepeaterMoveItemIndex = number | 'first' | 'last' | 'previous' | 'next';
19
+ /**
20
+ * Callback for preprocessing a newly created entity.
21
+ */
22
+ export type RepeaterPreprocessCallback<T> = (entity: EntityAccessor<T>) => void;
23
+ /**
24
+ * Information about a single repeater item, passed to the map callback.
25
+ */
26
+ export interface RepeaterItemInfo {
27
+ /** Index of the item in the sorted list */
28
+ index: number;
29
+ /** Whether this is the first item */
30
+ isFirst: boolean;
31
+ /** Whether this is the last item */
32
+ isLast: boolean;
33
+ /** Remove this item from the repeater */
34
+ remove: () => void;
35
+ /** Move this item up (to previous position). Only available when sortableBy is defined. */
36
+ moveUp: () => void;
37
+ /** Move this item down (to next position). Only available when sortableBy is defined. */
38
+ moveDown: () => void;
39
+ }
40
+ /**
41
+ * Items collection object passed to the repeater render function.
42
+ * Provides a type-safe map() method for iterating over items.
43
+ */
44
+ export interface RepeaterItems<TEntity, TSelected = TEntity, TBrand extends AnyBrand = AnyBrand, TEntityName extends string = string, TSchema extends Record<string, object> = Record<string, object>> {
45
+ /**
46
+ * Map over items with full type safety.
47
+ * Each item receives the entity accessor and item info (index, isFirst, isLast, remove, moveUp, moveDown).
48
+ */
49
+ map: <R>(fn: (entity: EntityAccessor<TEntity, TSelected, TBrand, TEntityName, TSchema>, info: RepeaterItemInfo) => R) => R[];
50
+ /** Number of items in the repeater */
51
+ length: number;
52
+ }
53
+ /**
54
+ * Methods available at the repeater level for adding items.
55
+ */
56
+ export interface RepeaterMethods<T = unknown> {
57
+ /**
58
+ * Adds a new item to the repeater.
59
+ * @param index - Where to add the item (default: 'last')
60
+ * @param preprocess - Optional callback to preprocess the new entity
61
+ */
62
+ addItem: (index?: RepeaterAddItemIndex, preprocess?: RepeaterPreprocessCallback<T>) => void;
63
+ /** Whether the repeater is empty */
64
+ isEmpty: boolean;
65
+ }
66
+ /**
67
+ * Render function type for the Repeater component.
68
+ */
69
+ export type RepeaterRenderFn<TEntity, TSelected = TEntity, TBrand extends AnyBrand = AnyBrand, TEntityName extends string = string, TSchema extends Record<string, object> = Record<string, object>> = (items: RepeaterItems<TEntity, TSelected, TBrand, TEntityName, TSchema>, methods: RepeaterMethods<TEntity>) => ReactNode;
70
+ /**
71
+ * Props for the Repeater component.
72
+ * Types flow from the field prop to the children callback for full type safety.
73
+ */
74
+ export interface RepeaterProps<TEntity, TSelected = TEntity, TBrand extends AnyBrand = AnyBrand, TEntityName extends string = string, TSchema extends Record<string, object> = Record<string, object>> {
75
+ /** The has-many relation field */
76
+ field: HasManyRef<TEntity, TSelected, TBrand, TEntityName, TSchema>;
77
+ /** Optional field name for sorting (must be a numeric field) */
78
+ sortableBy?: string;
79
+ /** Render function that receives items collection and methods */
80
+ children: RepeaterRenderFn<TEntity, TSelected, TBrand, TEntityName, TSchema>;
81
+ }
82
+ /**
83
+ * Minimal block definition for headless use.
84
+ */
85
+ export interface BlockDefinition {
86
+ label?: ReactNode;
87
+ }
88
+ /**
89
+ * Extended item info with block type discrimination.
90
+ */
91
+ export interface BlockRepeaterItemInfo extends RepeaterItemInfo {
92
+ /** Value of the discrimination field */
93
+ blockType: string | null;
94
+ /** Resolved block definition, undefined if block type is unknown */
95
+ block: {
96
+ name: string;
97
+ label?: ReactNode;
98
+ } | undefined;
99
+ }
100
+ /**
101
+ * Items collection for BlockRepeater with BlockRepeaterItemInfo.
102
+ */
103
+ export interface BlockRepeaterItems<TEntity, TSelected = TEntity, TBrand extends AnyBrand = AnyBrand, TEntityName extends string = string, TSchema extends Record<string, object> = Record<string, object>> {
104
+ map: <R>(fn: (entity: EntityAccessor<TEntity, TSelected, TBrand, TEntityName, TSchema>, info: BlockRepeaterItemInfo) => R) => R[];
105
+ /** Number of items in the repeater */
106
+ length: number;
107
+ }
108
+ /**
109
+ * Methods for BlockRepeater — addItem requires a block type.
110
+ */
111
+ export interface BlockRepeaterMethods<TBlockNames extends string> {
112
+ /** Add a new item with the given block type */
113
+ addItem: (type: TBlockNames, index?: RepeaterAddItemIndex) => void;
114
+ /** Whether the repeater is empty */
115
+ isEmpty: boolean;
116
+ /** List of all defined blocks */
117
+ blockList: ReadonlyArray<{
118
+ name: TBlockNames;
119
+ label?: ReactNode;
120
+ }>;
121
+ }
122
+ /**
123
+ * Render function type for BlockRepeater.
124
+ */
125
+ export type BlockRepeaterRenderFn<TEntity, TSelected = TEntity, TBrand extends AnyBrand = AnyBrand, TEntityName extends string = string, TSchema extends Record<string, object> = Record<string, object>, TBlockNames extends string = string> = (items: BlockRepeaterItems<TEntity, TSelected, TBrand, TEntityName, TSchema>, methods: BlockRepeaterMethods<TBlockNames>) => ReactNode;
126
+ /**
127
+ * Props for the BlockRepeater component.
128
+ */
129
+ export interface BlockRepeaterProps<TEntity, TSelected = TEntity, TBrand extends AnyBrand = AnyBrand, TEntityName extends string = string, TSchema extends Record<string, object> = Record<string, object>, TBlockNames extends string = string> {
130
+ /** The has-many relation field */
131
+ field: HasManyRef<TEntity, TSelected, TBrand, TEntityName, TSchema>;
132
+ /** Name of the scalar field used to discriminate block types */
133
+ discriminationField: string;
134
+ /** Optional field name for sorting (must be a numeric field) */
135
+ sortableBy?: string;
136
+ /** Block definitions keyed by block type name */
137
+ blocks: Record<TBlockNames, BlockDefinition>;
138
+ /** Render function that receives items collection and methods */
139
+ children: BlockRepeaterRenderFn<TEntity, TSelected, TBrand, TEntityName, TSchema, TBlockNames>;
140
+ }
141
+ //# sourceMappingURL=types.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"types.d.ts","sourceRoot":"","sources":["../src/types.ts"],"names":[],"mappings":"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"}
@@ -0,0 +1,11 @@
1
+ /**
2
+ * Moves an element from one position to another in an array.
3
+ * Returns a new array with the element moved.
4
+ *
5
+ * @param array - The source array
6
+ * @param from - The index to move from
7
+ * @param to - The index to move to
8
+ * @returns A new array with the element moved
9
+ */
10
+ export declare function arrayMove<T>(array: T[], from: number, to: number): T[];
11
+ //# sourceMappingURL=arrayMove.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"arrayMove.d.ts","sourceRoot":"","sources":["../../src/utils/arrayMove.ts"],"names":[],"mappings":"AAAA;;;;;;;;GAQG;AACH,wBAAgB,SAAS,CAAC,CAAC,EAAE,KAAK,EAAE,CAAC,EAAE,EAAE,IAAI,EAAE,MAAM,EAAE,EAAE,EAAE,MAAM,GAAG,CAAC,EAAE,CAItE"}
@@ -0,0 +1,4 @@
1
+ export { arrayMove } from './arrayMove.js';
2
+ export { sortEntities } from './sortEntities.js';
3
+ export { repairEntitiesOrder } from './repairEntitiesOrder.js';
4
+ //# sourceMappingURL=index.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../src/utils/index.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,SAAS,EAAE,MAAM,gBAAgB,CAAA;AAC1C,OAAO,EAAE,YAAY,EAAE,MAAM,mBAAmB,CAAA;AAChD,OAAO,EAAE,mBAAmB,EAAE,MAAM,0BAA0B,CAAA"}
@@ -0,0 +1,10 @@
1
+ import type { EntityAccessor } from '@contember/bindx';
2
+ /**
3
+ * Repairs the order field values of entities to be sequential (0, 1, 2, ...).
4
+ * Only updates values that differ from their expected index.
5
+ *
6
+ * @param items - Array of entity accessors (already sorted)
7
+ * @param orderField - Name of the numeric field to update
8
+ */
9
+ export declare function repairEntitiesOrder<T extends object, S = T>(items: EntityAccessor<T, S>[], orderField: string): void;
10
+ //# sourceMappingURL=repairEntitiesOrder.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"repairEntitiesOrder.d.ts","sourceRoot":"","sources":["../../src/utils/repairEntitiesOrder.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,cAAc,EAAY,MAAM,kBAAkB,CAAA;AAEhE;;;;;;GAMG;AACH,wBAAgB,mBAAmB,CAAC,CAAC,SAAS,MAAM,EAAE,CAAC,GAAG,CAAC,EAC1D,KAAK,EAAE,cAAc,CAAC,CAAC,EAAE,CAAC,CAAC,EAAE,EAC7B,UAAU,EAAE,MAAM,GAChB,IAAI,CASN"}
@@ -0,0 +1,11 @@
1
+ import type { EntityAccessor } from '@contember/bindx';
2
+ /**
3
+ * Sorts entities by a numeric order field.
4
+ * Returns a new sorted array.
5
+ *
6
+ * @param items - Array of entity accessors
7
+ * @param orderField - Name of the numeric field to sort by
8
+ * @returns New sorted array of entities
9
+ */
10
+ export declare function sortEntities<T extends object, S = T>(items: EntityAccessor<T, S>[], orderField: string | undefined): EntityAccessor<T, S>[];
11
+ //# sourceMappingURL=sortEntities.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"sortEntities.d.ts","sourceRoot":"","sources":["../../src/utils/sortEntities.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,cAAc,EAAY,MAAM,kBAAkB,CAAA;AAEhE;;;;;;;GAOG;AACH,wBAAgB,YAAY,CAAC,CAAC,SAAS,MAAM,EAAE,CAAC,GAAG,CAAC,EACnD,KAAK,EAAE,cAAc,CAAC,CAAC,EAAE,CAAC,CAAC,EAAE,EAC7B,UAAU,EAAE,MAAM,GAAG,SAAS,GAC5B,cAAc,CAAC,CAAC,EAAE,CAAC,CAAC,EAAE,CAcxB"}
package/package.json ADDED
@@ -0,0 +1,31 @@
1
+ {
2
+ "name": "@contember/bindx-repeater",
3
+ "version": "0.1.0",
4
+ "description": "Repeater components for has-many list management",
5
+ "type": "module",
6
+ "main": "./src/index.ts",
7
+ "exports": {
8
+ ".": "./src/index.ts"
9
+ },
10
+ "scripts": {
11
+ "build": "tsc --build",
12
+ "typecheck": "tsc --build"
13
+ },
14
+ "peerDependencies": {
15
+ "react": ">=18.0.0"
16
+ },
17
+ "dependencies": {
18
+ "@contember/bindx": "0.1.0",
19
+ "@contember/bindx-react": "0.1.0"
20
+ },
21
+ "files": [
22
+ "dist",
23
+ "src"
24
+ ],
25
+ "license": "MIT",
26
+ "repository": {
27
+ "type": "git",
28
+ "url": "https://github.com/contember/bindx.git",
29
+ "directory": "packages/bindx-repeater"
30
+ }
31
+ }
@@ -0,0 +1,266 @@
1
+ import React, { useMemo, type ReactElement, type ReactNode } from 'react'
2
+ import type {
3
+ EntityAccessor,
4
+ HasManyRef,
5
+ AnyBrand,
6
+ SelectionFieldMeta,
7
+ SelectionMeta,
8
+ FieldRef,
9
+ } from '@contember/bindx'
10
+ import { SelectionScope, FIELD_REF_META } from '@contember/bindx'
11
+ import { createCollectorProxy, mergeSelections, BINDX_COMPONENT, type SelectionProvider } from '@contember/bindx-react'
12
+ import type {
13
+ BlockRepeaterProps,
14
+ BlockRepeaterItems,
15
+ BlockRepeaterItemInfo,
16
+ BlockRepeaterMethods,
17
+ BlockDefinition,
18
+ RepeaterAddItemIndex,
19
+ } from '../types.js'
20
+ import { useSortedItems } from '../hooks/useSortedItems.js'
21
+ import { sortEntities } from '../utils/sortEntities.js'
22
+ import { repairEntitiesOrder } from '../utils/repairEntitiesOrder.js'
23
+ import { arrayMove } from '../utils/arrayMove.js'
24
+
25
+ /**
26
+ * Block repeater component for has-many relations with type discrimination.
27
+ *
28
+ * Each item has a discrimination field that determines its block type,
29
+ * allowing different rendering based on the block type.
30
+ *
31
+ * @example
32
+ * ```tsx
33
+ * <BlockRepeater
34
+ * field={entity.blocks}
35
+ * discriminationField="type"
36
+ * sortableBy="order"
37
+ * blocks={{
38
+ * text: { label: 'Text' },
39
+ * image: { label: 'Image' },
40
+ * }}
41
+ * >
42
+ * {(items, methods) => (
43
+ * <>
44
+ * {items.map((item, info) => {
45
+ * switch (info.blockType) {
46
+ * case 'text': return <div key={item.id}>{item.content.value}</div>
47
+ * case 'image': return <img key={item.id} src={item.url.value} />
48
+ * default: return null
49
+ * }
50
+ * })}
51
+ * {methods.blockList.map(b => (
52
+ * <button key={b.name} onClick={() => methods.addItem(b.name)}>
53
+ * Add {b.label ?? b.name}
54
+ * </button>
55
+ * ))}
56
+ * </>
57
+ * )}
58
+ * </BlockRepeater>
59
+ * ```
60
+ */
61
+ export function BlockRepeater<
62
+ TEntity extends object = object,
63
+ TSelected = TEntity,
64
+ TBrand extends AnyBrand = AnyBrand,
65
+ TEntityName extends string = string,
66
+ TSchema extends Record<string, object> = Record<string, object>,
67
+ TBlockNames extends string = string,
68
+ >({
69
+ field,
70
+ discriminationField,
71
+ sortableBy,
72
+ blocks,
73
+ children,
74
+ }: BlockRepeaterProps<TEntity, TSelected, TBrand, TEntityName, TSchema, TBlockNames>): ReactElement {
75
+ const sortedItems = useSortedItems(field, sortableBy)
76
+
77
+ const items = useMemo((): BlockRepeaterItems<TEntity, TSelected, TBrand, TEntityName, TSchema> => {
78
+ const createItemInfo = (
79
+ entity: EntityAccessor<TEntity, TSelected, TBrand, TEntityName, TSchema>,
80
+ index: number,
81
+ ): BlockRepeaterItemInfo => {
82
+ const isFirst = index === 0
83
+ const isLast = index === sortedItems.length - 1
84
+
85
+ const discriminationRef = (entity.$fields as Record<string, unknown>)[discriminationField] as FieldRef<string> | undefined
86
+ const blockType = discriminationRef?.value ?? null
87
+ const blockDef = blockType !== null ? (blocks as Record<string, BlockDefinition>)[blockType] : undefined
88
+ const block = blockDef !== undefined && blockType !== null
89
+ ? { name: blockType, label: blockDef.label }
90
+ : undefined
91
+
92
+ const remove = (): void => {
93
+ if (sortableBy) {
94
+ const items = sortEntities(field.items, sortableBy) as EntityAccessor<TEntity, TSelected>[]
95
+ const currentIndex = items.findIndex(item => item.id === entity.id)
96
+ if (currentIndex !== -1) {
97
+ items.splice(currentIndex, 1)
98
+ repairEntitiesOrder(items, sortableBy)
99
+ }
100
+ }
101
+ field.remove(entity.id)
102
+ }
103
+
104
+ const moveUp = (): void => {
105
+ if (!sortableBy || isFirst) return
106
+ const items = sortEntities(field.items, sortableBy) as EntityAccessor<TEntity, TSelected>[]
107
+ const currentIndex = items.findIndex(item => item.id === entity.id)
108
+ if (currentIndex === -1 || currentIndex === 0) return
109
+ const newItems = arrayMove(items, currentIndex, currentIndex - 1)
110
+ repairEntitiesOrder(newItems, sortableBy)
111
+ }
112
+
113
+ const moveDown = (): void => {
114
+ if (!sortableBy || isLast) return
115
+ const items = sortEntities(field.items, sortableBy) as EntityAccessor<TEntity, TSelected>[]
116
+ const currentIndex = items.findIndex(item => item.id === entity.id)
117
+ if (currentIndex === -1 || currentIndex === items.length - 1) return
118
+ const newItems = arrayMove(items, currentIndex, currentIndex + 1)
119
+ repairEntitiesOrder(newItems, sortableBy)
120
+ }
121
+
122
+ return { index, isFirst, isLast, remove, moveUp, moveDown, blockType, block }
123
+ }
124
+
125
+ return {
126
+ map: <R,>(
127
+ fn: (
128
+ entity: EntityAccessor<TEntity, TSelected, TBrand, TEntityName, TSchema>,
129
+ info: BlockRepeaterItemInfo,
130
+ ) => R,
131
+ ): R[] => {
132
+ return sortedItems.map((entity, index) => {
133
+ const info = createItemInfo(entity, index)
134
+ return fn(entity, info)
135
+ })
136
+ },
137
+ length: sortedItems.length,
138
+ }
139
+ }, [sortedItems, field, sortableBy, discriminationField, blocks])
140
+
141
+ const methods = useMemo((): BlockRepeaterMethods<TBlockNames> => {
142
+ const blockList = (Object.keys(blocks) as TBlockNames[]).map(name => ({
143
+ name,
144
+ label: (blocks as Record<string, BlockDefinition>)[name]?.label,
145
+ }))
146
+
147
+ const addItem = (
148
+ type: TBlockNames,
149
+ index: RepeaterAddItemIndex = 'last',
150
+ ): void => {
151
+ if (!sortableBy) {
152
+ if (index === 'last' || index === undefined) {
153
+ const entityId = field.add()
154
+ const items = field.items
155
+ const newEntity = items.find(item => item.id === entityId)
156
+ if (newEntity) {
157
+ const discriminationRef = (newEntity.$fields as Record<string, unknown>)[discriminationField] as FieldRef<string> | undefined
158
+ discriminationRef?.setValue(type)
159
+ }
160
+ return
161
+ }
162
+ throw new Error('Cannot add item at specific index without sortableBy field')
163
+ }
164
+
165
+ const currentItems = sortEntities(field.items, sortableBy) as EntityAccessor<TEntity, TSelected>[]
166
+
167
+ const resolvedIndex = (() => {
168
+ switch (index) {
169
+ case 'first':
170
+ return 0
171
+ case 'last':
172
+ case undefined:
173
+ return currentItems.length
174
+ default:
175
+ return index
176
+ }
177
+ })()
178
+
179
+ const entityId = field.add()
180
+ const items = field.items
181
+ const newEntity = items.find(item => item.id === entityId)
182
+
183
+ if (newEntity) {
184
+ const newSortedItems = [...currentItems]
185
+ newSortedItems.splice(resolvedIndex, 0, newEntity as EntityAccessor<TEntity, TSelected>)
186
+ repairEntitiesOrder(newSortedItems, sortableBy)
187
+
188
+ const discriminationRef = (newEntity.$fields as Record<string, unknown>)[discriminationField] as FieldRef<string> | undefined
189
+ discriminationRef?.setValue(type)
190
+ }
191
+ }
192
+
193
+ return {
194
+ addItem,
195
+ isEmpty: field.length === 0,
196
+ blockList,
197
+ }
198
+ }, [field, sortableBy, blocks, discriminationField])
199
+
200
+ return <>{children(items, methods)}</>
201
+ }
202
+
203
+ // Static method for selection extraction
204
+ const blockRepeaterWithSelection = BlockRepeater as typeof BlockRepeater & SelectionProvider & { [BINDX_COMPONENT]: true }
205
+
206
+ blockRepeaterWithSelection.getSelection = (
207
+ props: BlockRepeaterProps<unknown>,
208
+ collectNested: (children: ReactNode) => SelectionMeta,
209
+ ): SelectionFieldMeta => {
210
+ const meta = props.field[FIELD_REF_META]
211
+
212
+ const scope = new SelectionScope()
213
+ const collectorEntity = createCollectorProxy<unknown>(scope)
214
+
215
+ const mockItems: BlockRepeaterItems<unknown> = {
216
+ map: (fn) => {
217
+ fn(collectorEntity, {
218
+ index: 0,
219
+ isFirst: true,
220
+ isLast: true,
221
+ remove: () => {},
222
+ moveUp: () => {},
223
+ moveDown: () => {},
224
+ blockType: null,
225
+ block: undefined,
226
+ })
227
+ return []
228
+ },
229
+ length: 0,
230
+ }
231
+
232
+ const mockMethods: BlockRepeaterMethods<string> = {
233
+ addItem: () => {},
234
+ isEmpty: true,
235
+ blockList: [],
236
+ }
237
+
238
+ const syntheticChildren = props.children(mockItems, mockMethods)
239
+ const jsxSelection = collectNested(syntheticChildren)
240
+
241
+ const nestedSelection = scope.toSelectionMeta()
242
+ mergeSelections(nestedSelection, jsxSelection)
243
+
244
+ // Add discrimination field to selection
245
+ nestedSelection.fields.set(props.discriminationField, {
246
+ fieldName: props.discriminationField,
247
+ alias: props.discriminationField,
248
+ path: [...meta.path, meta.fieldName],
249
+ isArray: false,
250
+ isRelation: false,
251
+ nested: { fields: new Map() },
252
+ })
253
+
254
+ return {
255
+ fieldName: meta.fieldName,
256
+ alias: meta.fieldName,
257
+ path: meta.path,
258
+ isArray: true,
259
+ isRelation: true,
260
+ nested: nestedSelection,
261
+ }
262
+ }
263
+
264
+ blockRepeaterWithSelection[BINDX_COMPONENT] = true
265
+
266
+ export { blockRepeaterWithSelection as BlockRepeaterWithMeta }
@@ -0,0 +1,241 @@
1
+ import React, { useMemo, type ReactElement, type ReactNode } from 'react'
2
+ import type {
3
+ EntityAccessor,
4
+ HasManyRef,
5
+ AnyBrand,
6
+ SelectionFieldMeta,
7
+ SelectionMeta,
8
+ } from '@contember/bindx'
9
+ import { SelectionScope, FIELD_REF_META } from '@contember/bindx'
10
+ import { createCollectorProxy, mergeSelections, BINDX_COMPONENT, type SelectionProvider } from '@contember/bindx-react'
11
+ import type {
12
+ RepeaterProps,
13
+ RepeaterItems,
14
+ RepeaterItemInfo,
15
+ RepeaterMethods,
16
+ RepeaterAddItemIndex,
17
+ RepeaterPreprocessCallback,
18
+ } from '../types.js'
19
+ import { useSortedItems } from '../hooks/useSortedItems.js'
20
+ import { sortEntities } from '../utils/sortEntities.js'
21
+ import { repairEntitiesOrder } from '../utils/repairEntitiesOrder.js'
22
+ import { arrayMove } from '../utils/arrayMove.js'
23
+
24
+ /**
25
+ * Main repeater component for rendering has-many relations with full type safety.
26
+ *
27
+ * Uses a callback-style API where types flow from the `field` prop through
28
+ * the `items.map()` callback, ensuring compile-time type safety.
29
+ *
30
+ * @example
31
+ * ```tsx
32
+ * <Repeater field={author.articles} sortableBy="order">
33
+ * {(items, { addItem, isEmpty }) => (
34
+ * <>
35
+ * {isEmpty && <p>No articles</p>}
36
+ *
37
+ * {items.map((article, { index, isFirst, isLast, remove, moveUp, moveDown }) => (
38
+ * <div key={article.id}>
39
+ * {article.title.value}
40
+ * <button onClick={remove}>Remove</button>
41
+ * <button onClick={moveUp} disabled={isFirst}>↑</button>
42
+ * <button onClick={moveDown} disabled={isLast}>↓</button>
43
+ * </div>
44
+ * ))}
45
+ *
46
+ * <button onClick={() => addItem()}>Add</button>
47
+ * </>
48
+ * )}
49
+ * </Repeater>
50
+ * ```
51
+ */
52
+ export function Repeater<
53
+ TEntity extends object = object,
54
+ TSelected = TEntity,
55
+ TBrand extends AnyBrand = AnyBrand,
56
+ TEntityName extends string = string,
57
+ TSchema extends Record<string, object> = Record<string, object>,
58
+ >({
59
+ field,
60
+ sortableBy,
61
+ children,
62
+ }: RepeaterProps<TEntity, TSelected, TBrand, TEntityName, TSchema>): ReactElement {
63
+ const sortedItems = useSortedItems(field, sortableBy)
64
+
65
+ // Create stable items collection object
66
+ const items = useMemo((): RepeaterItems<TEntity, TSelected, TBrand, TEntityName, TSchema> => {
67
+ const createItemInfo = (
68
+ entity: EntityAccessor<TEntity, TSelected, TBrand, TEntityName, TSchema>,
69
+ index: number,
70
+ ): RepeaterItemInfo => {
71
+ const isFirst = index === 0
72
+ const isLast = index === sortedItems.length - 1
73
+
74
+ const remove = (): void => {
75
+ if (sortableBy) {
76
+ const items = sortEntities(field.items, sortableBy) as EntityAccessor<TEntity, TSelected>[]
77
+ const currentIndex = items.findIndex(item => item.id === entity.id)
78
+ if (currentIndex !== -1) {
79
+ items.splice(currentIndex, 1)
80
+ repairEntitiesOrder(items, sortableBy)
81
+ }
82
+ }
83
+ field.remove(entity.id)
84
+ }
85
+
86
+ const moveUp = (): void => {
87
+ if (!sortableBy || isFirst) return
88
+ const items = sortEntities(field.items, sortableBy) as EntityAccessor<TEntity, TSelected>[]
89
+ const currentIndex = items.findIndex(item => item.id === entity.id)
90
+ if (currentIndex === -1 || currentIndex === 0) return
91
+ const newItems = arrayMove(items, currentIndex, currentIndex - 1)
92
+ repairEntitiesOrder(newItems, sortableBy)
93
+ }
94
+
95
+ const moveDown = (): void => {
96
+ if (!sortableBy || isLast) return
97
+ const items = sortEntities(field.items, sortableBy) as EntityAccessor<TEntity, TSelected>[]
98
+ const currentIndex = items.findIndex(item => item.id === entity.id)
99
+ if (currentIndex === -1 || currentIndex === items.length - 1) return
100
+ const newItems = arrayMove(items, currentIndex, currentIndex + 1)
101
+ repairEntitiesOrder(newItems, sortableBy)
102
+ }
103
+
104
+ return { index, isFirst, isLast, remove, moveUp, moveDown }
105
+ }
106
+
107
+ return {
108
+ map: <R,>(
109
+ fn: (
110
+ entity: EntityAccessor<TEntity, TSelected, TBrand, TEntityName, TSchema>,
111
+ info: RepeaterItemInfo,
112
+ ) => R,
113
+ ): R[] => {
114
+ return sortedItems.map((entity, index) => {
115
+ const info = createItemInfo(entity, index)
116
+ return fn(entity, info)
117
+ })
118
+ },
119
+ length: sortedItems.length,
120
+ }
121
+ }, [sortedItems, field, sortableBy])
122
+
123
+ // Create methods object
124
+ const methods = useMemo((): RepeaterMethods<TEntity> => {
125
+ const addItem = (
126
+ index: RepeaterAddItemIndex = 'last',
127
+ preprocess?: RepeaterPreprocessCallback<TEntity>,
128
+ ): void => {
129
+ if (!sortableBy) {
130
+ if (index === 'last' || index === undefined) {
131
+ const entityId = field.add()
132
+ if (preprocess) {
133
+ const items = field.items
134
+ const newEntity = items.find(item => item.id === entityId)
135
+ if (newEntity) {
136
+ preprocess(newEntity as unknown as EntityAccessor<TEntity>)
137
+ }
138
+ }
139
+ return
140
+ }
141
+ throw new Error('Cannot add item at specific index without sortableBy field')
142
+ }
143
+
144
+ const sortedItems = sortEntities(field.items, sortableBy) as EntityAccessor<TEntity, TSelected>[]
145
+
146
+ const resolvedIndex = (() => {
147
+ switch (index) {
148
+ case 'first':
149
+ return 0
150
+ case 'last':
151
+ case undefined:
152
+ return sortedItems.length
153
+ default:
154
+ return index
155
+ }
156
+ })()
157
+
158
+ const entityId = field.add()
159
+ const items = field.items
160
+ const newEntity = items.find(item => item.id === entityId)
161
+
162
+ if (newEntity) {
163
+ const newSortedItems = [...sortedItems]
164
+ newSortedItems.splice(resolvedIndex, 0, newEntity as EntityAccessor<TEntity, TSelected>)
165
+ repairEntitiesOrder(newSortedItems, sortableBy)
166
+
167
+ if (preprocess) {
168
+ preprocess(newEntity as unknown as EntityAccessor<TEntity>)
169
+ }
170
+ }
171
+ }
172
+
173
+ return {
174
+ addItem,
175
+ isEmpty: field.length === 0,
176
+ }
177
+ }, [field, sortableBy])
178
+
179
+ // Call children with items and methods
180
+ return <>{children(items, methods)}</>
181
+ }
182
+
183
+ // Static method for selection extraction
184
+ const repeaterWithSelection = Repeater as typeof Repeater & SelectionProvider & { [BINDX_COMPONENT]: true }
185
+
186
+ repeaterWithSelection.getSelection = (
187
+ props: RepeaterProps<unknown>,
188
+ collectNested: (children: ReactNode) => SelectionMeta,
189
+ ): SelectionFieldMeta => {
190
+ const meta = props.field[FIELD_REF_META]
191
+
192
+ // Create scope and collector proxy
193
+ const scope = new SelectionScope()
194
+ const collectorEntity = createCollectorProxy<unknown>(scope)
195
+
196
+ // Create mock items object for collection phase
197
+ const mockItems: RepeaterItems<unknown> = {
198
+ map: (fn) => {
199
+ // Invoke callback with collector proxy to track field access
200
+ fn(collectorEntity, {
201
+ index: 0,
202
+ isFirst: true,
203
+ isLast: true,
204
+ remove: () => {},
205
+ moveUp: () => {},
206
+ moveDown: () => {},
207
+ })
208
+ return []
209
+ },
210
+ length: 0,
211
+ }
212
+
213
+ // Create mock methods
214
+ const mockMethods: RepeaterMethods<unknown> = {
215
+ addItem: () => {},
216
+ isEmpty: true,
217
+ }
218
+
219
+ // Invoke children callback - this tracks all field access
220
+ const syntheticChildren = props.children(mockItems, mockMethods)
221
+
222
+ // Also analyze JSX structure
223
+ const jsxSelection = collectNested(syntheticChildren)
224
+
225
+ // Merge both tracking methods
226
+ const nestedSelection = scope.toSelectionMeta()
227
+ mergeSelections(nestedSelection, jsxSelection)
228
+
229
+ return {
230
+ fieldName: meta.fieldName,
231
+ alias: meta.fieldName,
232
+ path: meta.path,
233
+ isArray: true,
234
+ isRelation: true,
235
+ nested: nestedSelection,
236
+ }
237
+ }
238
+
239
+ repeaterWithSelection[BINDX_COMPONENT] = true
240
+
241
+ export { repeaterWithSelection as RepeaterWithMeta }
@@ -0,0 +1,2 @@
1
+ export { Repeater, RepeaterWithMeta } from './Repeater.js'
2
+ export { BlockRepeater, BlockRepeaterWithMeta } from './BlockRepeater.js'
@@ -0,0 +1 @@
1
+ export { useSortedItems } from './useSortedItems.js'
@@ -0,0 +1,38 @@
1
+ import { useEffect, useMemo } from 'react'
2
+ import type { EntityAccessor, HasManyRef, AnyBrand } from '@contember/bindx'
3
+ import { sortEntities } from '../utils/sortEntities.js'
4
+ import { repairEntitiesOrder } from '../utils/repairEntitiesOrder.js'
5
+
6
+ /**
7
+ * Hook that returns sorted items from a has-many ref and repairs order field values.
8
+ *
9
+ * @param hasMany - The has-many ref
10
+ * @param orderField - Optional field name for sorting
11
+ * @returns Sorted array of entity accessors
12
+ */
13
+ export function useSortedItems<
14
+ T extends object,
15
+ S = T,
16
+ TBrand extends AnyBrand = AnyBrand,
17
+ TEntityName extends string = string,
18
+ TSchema extends Record<string, object> = Record<string, object>,
19
+ >(
20
+ hasMany: HasManyRef<T, S, TBrand, TEntityName, TSchema>,
21
+ orderField: string | undefined,
22
+ ): EntityAccessor<T, S, TBrand, TEntityName, TSchema>[] {
23
+ const items = hasMany.items
24
+
25
+ const sortedItems = useMemo(
26
+ () => sortEntities(items, orderField) as EntityAccessor<T, S, TBrand, TEntityName, TSchema>[],
27
+ [items, orderField],
28
+ )
29
+
30
+ useEffect(() => {
31
+ if (!orderField) {
32
+ return
33
+ }
34
+ repairEntitiesOrder(sortedItems, orderField)
35
+ }, [orderField, sortedItems])
36
+
37
+ return sortedItems
38
+ }
package/src/index.ts ADDED
@@ -0,0 +1,37 @@
1
+ /**
2
+ * Repeater components for has-many list management with Bindx
3
+ *
4
+ * @packageDocumentation
5
+ */
6
+
7
+ // Types
8
+ export type {
9
+ RepeaterAddItemIndex,
10
+ RepeaterMoveItemIndex,
11
+ RepeaterPreprocessCallback,
12
+ RepeaterItemInfo,
13
+ RepeaterItems,
14
+ RepeaterMethods,
15
+ RepeaterRenderFn,
16
+ RepeaterProps,
17
+ BlockDefinition,
18
+ BlockRepeaterItemInfo,
19
+ BlockRepeaterItems,
20
+ BlockRepeaterMethods,
21
+ BlockRepeaterRenderFn,
22
+ BlockRepeaterProps,
23
+ } from './types.js'
24
+
25
+ // Hooks (internal, but exposed for advanced use cases)
26
+ export { useSortedItems } from './hooks/index.js'
27
+
28
+ // Utils
29
+ export {
30
+ arrayMove,
31
+ sortEntities,
32
+ repairEntitiesOrder,
33
+ } from './utils/index.js'
34
+
35
+ // Components
36
+ export { Repeater, RepeaterWithMeta } from './components/index.js'
37
+ export { BlockRepeater, BlockRepeaterWithMeta } from './components/index.js'
package/src/types.ts ADDED
@@ -0,0 +1,222 @@
1
+ import type { ReactNode } from 'react'
2
+ import type { EntityAccessor, HasManyRef, AnyBrand, FieldRef } from '@contember/bindx'
3
+
4
+ /**
5
+ * Index for adding items to the repeater.
6
+ * - number: Adds at the specified index
7
+ * - 'first': Adds at the beginning
8
+ * - 'last' or undefined: Adds at the end
9
+ */
10
+ export type RepeaterAddItemIndex = number | 'first' | 'last' | undefined
11
+
12
+ /**
13
+ * Index for moving items within the repeater.
14
+ * - number: Moves to the specified index
15
+ * - 'first': Moves to the beginning
16
+ * - 'last': Moves to the end
17
+ * - 'previous': Moves to the previous position
18
+ * - 'next': Moves to the next position
19
+ */
20
+ export type RepeaterMoveItemIndex = number | 'first' | 'last' | 'previous' | 'next'
21
+
22
+ /**
23
+ * Callback for preprocessing a newly created entity.
24
+ */
25
+ export type RepeaterPreprocessCallback<T> = (entity: EntityAccessor<T>) => void
26
+
27
+ /**
28
+ * Information about a single repeater item, passed to the map callback.
29
+ */
30
+ export interface RepeaterItemInfo {
31
+ /** Index of the item in the sorted list */
32
+ index: number
33
+
34
+ /** Whether this is the first item */
35
+ isFirst: boolean
36
+
37
+ /** Whether this is the last item */
38
+ isLast: boolean
39
+
40
+ /** Remove this item from the repeater */
41
+ remove: () => void
42
+
43
+ /** Move this item up (to previous position). Only available when sortableBy is defined. */
44
+ moveUp: () => void
45
+
46
+ /** Move this item down (to next position). Only available when sortableBy is defined. */
47
+ moveDown: () => void
48
+ }
49
+
50
+ /**
51
+ * Items collection object passed to the repeater render function.
52
+ * Provides a type-safe map() method for iterating over items.
53
+ */
54
+ export interface RepeaterItems<
55
+ TEntity,
56
+ TSelected = TEntity,
57
+ TBrand extends AnyBrand = AnyBrand,
58
+ TEntityName extends string = string,
59
+ TSchema extends Record<string, object> = Record<string, object>,
60
+ > {
61
+ /**
62
+ * Map over items with full type safety.
63
+ * Each item receives the entity accessor and item info (index, isFirst, isLast, remove, moveUp, moveDown).
64
+ */
65
+ map: <R>(
66
+ fn: (
67
+ entity: EntityAccessor<TEntity, TSelected, TBrand, TEntityName, TSchema>,
68
+ info: RepeaterItemInfo,
69
+ ) => R
70
+ ) => R[]
71
+
72
+ /** Number of items in the repeater */
73
+ length: number
74
+ }
75
+
76
+ /**
77
+ * Methods available at the repeater level for adding items.
78
+ */
79
+ export interface RepeaterMethods<T = unknown> {
80
+ /**
81
+ * Adds a new item to the repeater.
82
+ * @param index - Where to add the item (default: 'last')
83
+ * @param preprocess - Optional callback to preprocess the new entity
84
+ */
85
+ addItem: (index?: RepeaterAddItemIndex, preprocess?: RepeaterPreprocessCallback<T>) => void
86
+
87
+ /** Whether the repeater is empty */
88
+ isEmpty: boolean
89
+ }
90
+
91
+ /**
92
+ * Render function type for the Repeater component.
93
+ */
94
+ export type RepeaterRenderFn<
95
+ TEntity,
96
+ TSelected = TEntity,
97
+ TBrand extends AnyBrand = AnyBrand,
98
+ TEntityName extends string = string,
99
+ TSchema extends Record<string, object> = Record<string, object>,
100
+ > = (
101
+ items: RepeaterItems<TEntity, TSelected, TBrand, TEntityName, TSchema>,
102
+ methods: RepeaterMethods<TEntity>,
103
+ ) => ReactNode
104
+
105
+ /**
106
+ * Props for the Repeater component.
107
+ * Types flow from the field prop to the children callback for full type safety.
108
+ */
109
+ export interface RepeaterProps<
110
+ TEntity,
111
+ TSelected = TEntity,
112
+ TBrand extends AnyBrand = AnyBrand,
113
+ TEntityName extends string = string,
114
+ TSchema extends Record<string, object> = Record<string, object>,
115
+ > {
116
+ /** The has-many relation field */
117
+ field: HasManyRef<TEntity, TSelected, TBrand, TEntityName, TSchema>
118
+
119
+ /** Optional field name for sorting (must be a numeric field) */
120
+ sortableBy?: string
121
+
122
+ /** Render function that receives items collection and methods */
123
+ children: RepeaterRenderFn<TEntity, TSelected, TBrand, TEntityName, TSchema>
124
+ }
125
+
126
+ // ============================================================================
127
+ // Block Repeater Types
128
+ // ============================================================================
129
+
130
+ /**
131
+ * Minimal block definition for headless use.
132
+ */
133
+ export interface BlockDefinition {
134
+ label?: ReactNode
135
+ }
136
+
137
+ /**
138
+ * Extended item info with block type discrimination.
139
+ */
140
+ export interface BlockRepeaterItemInfo extends RepeaterItemInfo {
141
+ /** Value of the discrimination field */
142
+ blockType: string | null
143
+ /** Resolved block definition, undefined if block type is unknown */
144
+ block: { name: string; label?: ReactNode } | undefined
145
+ }
146
+
147
+ /**
148
+ * Items collection for BlockRepeater with BlockRepeaterItemInfo.
149
+ */
150
+ export interface BlockRepeaterItems<
151
+ TEntity,
152
+ TSelected = TEntity,
153
+ TBrand extends AnyBrand = AnyBrand,
154
+ TEntityName extends string = string,
155
+ TSchema extends Record<string, object> = Record<string, object>,
156
+ > {
157
+ map: <R>(
158
+ fn: (
159
+ entity: EntityAccessor<TEntity, TSelected, TBrand, TEntityName, TSchema>,
160
+ info: BlockRepeaterItemInfo,
161
+ ) => R,
162
+ ) => R[]
163
+
164
+ /** Number of items in the repeater */
165
+ length: number
166
+ }
167
+
168
+ /**
169
+ * Methods for BlockRepeater — addItem requires a block type.
170
+ */
171
+ export interface BlockRepeaterMethods<TBlockNames extends string> {
172
+ /** Add a new item with the given block type */
173
+ addItem: (type: TBlockNames, index?: RepeaterAddItemIndex) => void
174
+
175
+ /** Whether the repeater is empty */
176
+ isEmpty: boolean
177
+
178
+ /** List of all defined blocks */
179
+ blockList: ReadonlyArray<{ name: TBlockNames; label?: ReactNode }>
180
+ }
181
+
182
+ /**
183
+ * Render function type for BlockRepeater.
184
+ */
185
+ export type BlockRepeaterRenderFn<
186
+ TEntity,
187
+ TSelected = TEntity,
188
+ TBrand extends AnyBrand = AnyBrand,
189
+ TEntityName extends string = string,
190
+ TSchema extends Record<string, object> = Record<string, object>,
191
+ TBlockNames extends string = string,
192
+ > = (
193
+ items: BlockRepeaterItems<TEntity, TSelected, TBrand, TEntityName, TSchema>,
194
+ methods: BlockRepeaterMethods<TBlockNames>,
195
+ ) => ReactNode
196
+
197
+ /**
198
+ * Props for the BlockRepeater component.
199
+ */
200
+ export interface BlockRepeaterProps<
201
+ TEntity,
202
+ TSelected = TEntity,
203
+ TBrand extends AnyBrand = AnyBrand,
204
+ TEntityName extends string = string,
205
+ TSchema extends Record<string, object> = Record<string, object>,
206
+ TBlockNames extends string = string,
207
+ > {
208
+ /** The has-many relation field */
209
+ field: HasManyRef<TEntity, TSelected, TBrand, TEntityName, TSchema>
210
+
211
+ /** Name of the scalar field used to discriminate block types */
212
+ discriminationField: string
213
+
214
+ /** Optional field name for sorting (must be a numeric field) */
215
+ sortableBy?: string
216
+
217
+ /** Block definitions keyed by block type name */
218
+ blocks: Record<TBlockNames, BlockDefinition>
219
+
220
+ /** Render function that receives items collection and methods */
221
+ children: BlockRepeaterRenderFn<TEntity, TSelected, TBrand, TEntityName, TSchema, TBlockNames>
222
+ }
@@ -0,0 +1,14 @@
1
+ /**
2
+ * Moves an element from one position to another in an array.
3
+ * Returns a new array with the element moved.
4
+ *
5
+ * @param array - The source array
6
+ * @param from - The index to move from
7
+ * @param to - The index to move to
8
+ * @returns A new array with the element moved
9
+ */
10
+ export function arrayMove<T>(array: T[], from: number, to: number): T[] {
11
+ const newArray = array.slice()
12
+ newArray.splice(to, 0, newArray.splice(from, 1)[0]!)
13
+ return newArray
14
+ }
@@ -0,0 +1,3 @@
1
+ export { arrayMove } from './arrayMove.js'
2
+ export { sortEntities } from './sortEntities.js'
3
+ export { repairEntitiesOrder } from './repairEntitiesOrder.js'
@@ -0,0 +1,22 @@
1
+ import type { EntityAccessor, FieldRef } from '@contember/bindx'
2
+
3
+ /**
4
+ * Repairs the order field values of entities to be sequential (0, 1, 2, ...).
5
+ * Only updates values that differ from their expected index.
6
+ *
7
+ * @param items - Array of entity accessors (already sorted)
8
+ * @param orderField - Name of the numeric field to update
9
+ */
10
+ export function repairEntitiesOrder<T extends object, S = T>(
11
+ items: EntityAccessor<T, S>[],
12
+ orderField: string,
13
+ ): void {
14
+ for (let i = 0; i < items.length; i++) {
15
+ const entity = items[i]!
16
+ const field = (entity as Record<string, unknown>)[orderField] as FieldRef<number> | undefined
17
+
18
+ if (field && field.value !== i) {
19
+ field.setValue(i)
20
+ }
21
+ }
22
+ }
@@ -0,0 +1,28 @@
1
+ import type { EntityAccessor, FieldRef } from '@contember/bindx'
2
+
3
+ /**
4
+ * Sorts entities by a numeric order field.
5
+ * Returns a new sorted array.
6
+ *
7
+ * @param items - Array of entity accessors
8
+ * @param orderField - Name of the numeric field to sort by
9
+ * @returns New sorted array of entities
10
+ */
11
+ export function sortEntities<T extends object, S = T>(
12
+ items: EntityAccessor<T, S>[],
13
+ orderField: string | undefined,
14
+ ): EntityAccessor<T, S>[] {
15
+ if (!orderField) {
16
+ return items
17
+ }
18
+
19
+ return [...items].sort((a, b) => {
20
+ const aField = (a as Record<string, unknown>)[orderField] as FieldRef<number> | undefined
21
+ const bField = (b as Record<string, unknown>)[orderField] as FieldRef<number> | undefined
22
+
23
+ const aValue = aField?.value ?? Number.MAX_SAFE_INTEGER
24
+ const bValue = bField?.value ?? Number.MAX_SAFE_INTEGER
25
+
26
+ return aValue - bValue
27
+ })
28
+ }