@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.
- package/dist/components/BlockRepeater.d.ts +46 -0
- package/dist/components/BlockRepeater.d.ts.map +1 -0
- package/dist/components/Repeater.d.ts +38 -0
- package/dist/components/Repeater.d.ts.map +1 -0
- package/dist/components/index.d.ts +3 -0
- package/dist/components/index.d.ts.map +1 -0
- package/dist/hooks/index.d.ts +2 -0
- package/dist/hooks/index.d.ts.map +1 -0
- package/dist/hooks/useSortedItems.d.ts +10 -0
- package/dist/hooks/useSortedItems.d.ts.map +1 -0
- package/dist/index.d.ts +11 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/types.d.ts +141 -0
- package/dist/types.d.ts.map +1 -0
- package/dist/utils/arrayMove.d.ts +11 -0
- package/dist/utils/arrayMove.d.ts.map +1 -0
- package/dist/utils/index.d.ts +4 -0
- package/dist/utils/index.d.ts.map +1 -0
- package/dist/utils/repairEntitiesOrder.d.ts +10 -0
- package/dist/utils/repairEntitiesOrder.d.ts.map +1 -0
- package/dist/utils/sortEntities.d.ts +11 -0
- package/dist/utils/sortEntities.d.ts.map +1 -0
- package/package.json +31 -0
- package/src/components/BlockRepeater.tsx +266 -0
- package/src/components/Repeater.tsx +241 -0
- package/src/components/index.ts +2 -0
- package/src/hooks/index.ts +1 -0
- package/src/hooks/useSortedItems.ts +38 -0
- package/src/index.ts +37 -0
- package/src/types.ts +222 -0
- package/src/utils/arrayMove.ts +14 -0
- package/src/utils/index.ts +3 -0
- package/src/utils/repairEntitiesOrder.ts +22 -0
- package/src/utils/sortEntities.ts +28 -0
|
@@ -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 @@
|
|
|
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 @@
|
|
|
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"}
|
package/dist/index.d.ts
ADDED
|
@@ -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"}
|
package/dist/types.d.ts
ADDED
|
@@ -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 @@
|
|
|
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 @@
|
|
|
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,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
|
+
}
|