rimecms 0.23.27 → 0.24.1

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.
Files changed (78) hide show
  1. package/README.md +32 -19
  2. package/dist/adapter-sqlite/where.js +202 -154
  3. package/dist/core/config/shared/find-title.d.ts +2 -2
  4. package/dist/core/config/shared/find-title.js +1 -1
  5. package/dist/core/operations/hooks/before-read/set-document-title.server.js +15 -2
  6. package/dist/fields/relation/component/Cell.svelte +10 -4
  7. package/dist/fields/relation/component/default/Default.svelte +1 -1
  8. package/dist/fields/rich-text/index.d.ts +2 -0
  9. package/dist/fields/rich-text/index.js +4 -0
  10. package/dist/panel/components/sections/collection/list/row/Row.svelte +2 -0
  11. package/dist/panel/components/ui/breadcrumb/BreadCrumb.svelte +4 -0
  12. package/dist/panel/components/ui/page-header/PageHeader.svelte +4 -0
  13. package/dist/panel/context/documentForm.svelte.js +13 -1
  14. package/dist/site/components/avatar/avatar.svelte +1 -1
  15. package/dist/site/components/avatar/avatar.svelte.d.ts +1 -1
  16. package/dist/site/components/button/button.svelte +2 -2
  17. package/dist/site/components/card-document/card-document.svelte +92 -0
  18. package/dist/site/components/card-document/card-document.svelte.d.ts +7 -0
  19. package/dist/site/components/dialog/dialog-content.css +47 -0
  20. package/dist/site/components/dialog/dialog-content.svelte +28 -0
  21. package/dist/site/components/dialog/dialog-content.svelte.d.ts +10 -0
  22. package/dist/site/components/dialog/dialog-overlay.css +8 -0
  23. package/dist/site/components/dialog/dialog-overlay.svelte +12 -0
  24. package/dist/site/components/dialog/dialog-overlay.svelte.d.ts +5 -0
  25. package/dist/site/components/dialog/index.d.ts +8 -0
  26. package/dist/site/components/dialog/index.js +10 -0
  27. package/dist/site/components/feed/feed.svelte +12 -9
  28. package/dist/site/components/feed/feed.svelte.d.ts +5 -17
  29. package/dist/site/components/folder/folder.svelte +53 -0
  30. package/dist/site/components/folder/folder.svelte.d.ts +7 -0
  31. package/dist/site/components/nav/nav.svelte +15 -11
  32. package/dist/site/components/nav/post-button/post-button.svelte +52 -0
  33. package/dist/site/components/nav/post-button/post-button.svelte.d.ts +3 -0
  34. package/dist/site/components/post/post-actions/comment-button.svelte +115 -0
  35. package/dist/site/components/post/post-actions/comment-button.svelte.d.ts +6 -0
  36. package/dist/site/components/post/post-actions/like-button.svelte +76 -0
  37. package/dist/site/components/post/post-actions/like-button.svelte.d.ts +6 -0
  38. package/dist/site/components/post/post-actions/post-actions.svelte +21 -11
  39. package/dist/site/components/post/post-actions/post-actions.svelte.d.ts +1 -1
  40. package/dist/site/components/post/post-attachments.svelte +26 -0
  41. package/dist/site/components/post/post-attachments.svelte.d.ts +6 -0
  42. package/dist/site/components/post/post-date.svelte +12 -0
  43. package/dist/site/components/post/post-date.svelte.d.ts +6 -0
  44. package/dist/site/components/post/post-with-comments.svelte +42 -0
  45. package/dist/site/components/post/post-with-comments.svelte.d.ts +6 -0
  46. package/dist/site/components/post/post.svelte +28 -11
  47. package/dist/site/components/post/post.svelte.d.ts +1 -2
  48. package/dist/site/components/rich-text/bubble-menu/bubble-menu.css +17 -0
  49. package/dist/site/components/rich-text/bubble-menu/bubble-menu.svelte +119 -0
  50. package/dist/site/components/rich-text/bubble-menu/bubble-menu.svelte.d.ts +11 -0
  51. package/dist/site/components/rich-text/bubble-menu/icon-button/icon-button.css +15 -0
  52. package/dist/site/components/rich-text/bubble-menu/icon-button/icon-button.svelte +31 -0
  53. package/dist/site/components/rich-text/bubble-menu/icon-button/icon-button.svelte.d.ts +13 -0
  54. package/dist/site/components/rich-text/context.svelte.d.ts +12 -0
  55. package/dist/site/components/rich-text/context.svelte.js +32 -0
  56. package/dist/site/components/rich-text/features/bold.d.ts +2 -0
  57. package/dist/site/components/rich-text/features/bold.js +21 -0
  58. package/dist/site/components/rich-text/features/link/component/link-selector.css +4 -0
  59. package/dist/site/components/rich-text/features/link/component/link-selector.svelte +214 -0
  60. package/dist/site/components/rich-text/features/link/component/link-selector.svelte.d.ts +11 -0
  61. package/dist/site/components/rich-text/features/link/link.d.ts +2 -0
  62. package/dist/site/components/rich-text/features/link/link.js +36 -0
  63. package/dist/site/components/rich-text/rich-text.css +170 -0
  64. package/dist/site/components/rich-text/rich-text.svelte +84 -0
  65. package/dist/site/components/rich-text/rich-text.svelte.d.ts +9 -0
  66. package/dist/site/components/upload-thumbnail/upload-thumbnail.svelte +76 -0
  67. package/dist/site/components/upload-thumbnail/upload-thumbnail.svelte.d.ts +8 -0
  68. package/dist/site/components/upload-thumbnail/util.d.ts +1 -0
  69. package/dist/site/components/upload-thumbnail/util.js +13 -0
  70. package/dist/site/components/user-inline/user-inline.svelte +1 -1
  71. package/dist/site/components/user-inline/user-inline.svelte.d.ts +1 -1
  72. package/dist/site/styles/colors.css +2 -0
  73. package/dist/site/styles/fonts.css +160 -0
  74. package/dist/site/styles/fonts.mixins.css +5 -0
  75. package/dist/site/styles/main.css +8 -6
  76. package/dist/site/styles/shadows.css +10 -0
  77. package/dist/site/styles/sizes.css +43 -0
  78. package/package.json +1 -1
package/README.md CHANGED
@@ -31,6 +31,7 @@ Headless CMS powered by SvelteKit.
31
31
  ### Content Management
32
32
 
33
33
  Fields types:
34
+
34
35
  - Blocks
35
36
  - Tree (nested array)
36
37
  - Tabs
@@ -52,6 +53,7 @@ Fields types:
52
53
  npx sv create my-app
53
54
  cd my-app
54
55
  ```
56
+
55
57
  > [!NOTE]
56
58
  > Select TypeScript when prompted
57
59
 
@@ -83,7 +85,7 @@ import { sveltekit } from '@sveltejs/kit/vite';
83
85
  import { rime } from 'rimecms/vite';
84
86
 
85
87
  export default defineConfig({
86
- plugins: [rime(), sveltekit()]
88
+ plugins: [rime(), sveltekit()]
87
89
  });
88
90
  ```
89
91
 
@@ -198,29 +200,37 @@ export default rime({
198
200
 
199
201
  ```ts
200
202
  export const load = async (event: LayoutServerLoadEvent) => {
201
- const { rime } = event.locals;
202
- // Get an Area document
203
- const menu = await rime.area('menu').find();
204
- // Get all pages documents
205
- const pages = await rime.collection('pages').findAll({ locale: 'en' });
206
- // Get a page byId
207
- const home = await rime.collection('pages').findById({ locale: 'en', id: 'some-id' });
208
- // Get a user with a query
209
- const [user] = await rime.collection('users').find({
210
- query: `where[email][equals]=some@email.com` // qs query or ParsedQsQuery
211
- });
212
- // Get some config values
213
- const languages = rime.config.getLocalesCodes();
214
- const collections = rime.config.collections;
215
- //...
203
+ const { rime } = event.locals;
204
+ // Get an Area document
205
+ const menu = await rime.area('menu').find();
206
+ // Get all pages documents
207
+ const pages = await rime.collection('pages').findAll({ locale: 'en' });
208
+ // Get a page byId
209
+ const home = await rime.collection('pages').findById({ locale: 'en', id: 'some-id' });
210
+ // Get a user with a query
211
+ const [user] = await rime.collection('users').find({
212
+ query: `where[email][equals]=some@email.com` // qs query or ParsedQsQuery
213
+ });
214
+ // Get some config values
215
+ const languages = rime.config.getLocalesCodes();
216
+ const collections = rime.config.collections;
217
+ //...
216
218
  };
217
219
  ```
218
220
 
219
221
  ### From the API :
222
+
220
223
  ```ts
221
- const { docs } = await fetch('http://localhost:5173/api/pages').then(r => r.json())
222
- const { docs } = await fetch('http://localhost:5173/api/pages?sort=title&limit=1').then(r => r.json())
223
- const { docs } = await fetch('http://localhost:5173/api/pages?where[author][like]=some-id&locale=en`;').then(r => r.json())
224
+ const { docs } = await fetch('http://localhost:5173/api/pages').then((r) => r.json());
225
+ const { docs } = await fetch('http://localhost:5173/api/pages?sort=title&limit=1').then((r) =>
226
+ r.json()
227
+ );
228
+ const { docs } = await fetch(
229
+ 'http://localhost:5173/api/pages?where[author][equals]=some-id&locale=en`;'
230
+ ).then((r) => r.json());
231
+ const { docs } = await fetch(
232
+ 'http://localhost:5173/api/pages?where[author.email][equals]=some@email.com&locale=en`;'
233
+ ).then((r) => r.json());
224
234
  ```
225
235
 
226
236
  ## DEPLOYING
@@ -228,10 +238,12 @@ const { docs } = await fetch('http://localhost:5173/api/pages?where[author][like
228
238
  For now I am using it with @svelte/adapter-node, other adapter not tested and probably not working.
229
239
 
230
240
  With the node adapter :
241
+
231
242
  ```sh
232
243
  npx rime build
233
244
  npx rime build -d # to copy the database directory
234
245
  ```
246
+
235
247
  It's doing bascically `vite build` under the hood and create the polka server file inside an app directory, plus giving some info on how to run it.
236
248
 
237
249
  ## ROADMAP
@@ -246,6 +258,7 @@ It's doing bascically `vite build` under the hood and create the polka server fi
246
258
  - [x] Document version
247
259
  - [x] collection nested
248
260
  - [x] more better-auth integration
261
+ - [x] Handle relation poperties in queries
249
262
  - [~] Documentation
250
263
  - [ ] Live Edit system in practice
251
264
  - [ ] auto-saved draft
@@ -7,61 +7,40 @@ import {} from '../index.js';
7
7
  import * as drizzleORM from 'drizzle-orm';
8
8
  import { and, eq, getTableColumns, inArray, or } from 'drizzle-orm';
9
9
  export const buildWhereParam = ({ query, slug, db, locale, tables, configCtx }) => {
10
+ // Helper to get table by key with correct typing
10
11
  function getTable(key) {
11
12
  return tables[key];
12
13
  }
13
- const table = getTable(slug);
14
- const tableNameLocales = withLocalesSuffix(slug);
15
- const tableLocales = getTable(tableNameLocales);
16
- const localizedColumns = locale && tableNameLocales in tables ? Object.keys(getTableColumns(tableLocales)) : [];
17
- const unlocalizedColumns = Object.keys(getTableColumns(table));
14
+ function getTablesAndColumns(slug) {
15
+ // Get main table and localized table if applicable
16
+ const table = getTable(slug);
17
+ const tableNameLocales = withLocalesSuffix(slug);
18
+ const tableLocales = getTable(tableNameLocales);
19
+ // Get localized and unlocalized columns
20
+ const localizedColumns = locale && tableNameLocales in tables ? Object.keys(getTableColumns(tableLocales)) : [];
21
+ const unlocalizedColumns = Object.keys(getTableColumns(table));
22
+ return { table, tableLocales, localizedColumns, unlocalizedColumns };
23
+ }
24
+ const {
25
+ // Get main table and localized table if applicable
26
+ table, tableLocales, localizedColumns, unlocalizedColumns } = getTablesAndColumns(slug);
18
27
  const buildCondition = (conditionObject) => {
19
28
  // Handle nested AND conditions
20
29
  if ('and' in conditionObject && Array.isArray(conditionObject.and)) {
21
- const subConditions = conditionObject.and
22
- .map((condition) => buildCondition(condition))
23
- .filter(Boolean);
30
+ const subConditions = conditionObject.and.map((condition) => buildCondition(condition)).filter(Boolean);
24
31
  return subConditions.length ? and(...subConditions) : false;
25
32
  }
26
33
  // Handle nested OR conditions
27
34
  if ('or' in conditionObject && Array.isArray(conditionObject.or)) {
28
- const subConditions = conditionObject.or
29
- .map((condition) => buildCondition(condition))
30
- .filter(Boolean);
35
+ const subConditions = conditionObject.or.map((condition) => buildCondition(condition)).filter(Boolean);
31
36
  return subConditions.length ? or(...subConditions) : false;
32
37
  }
33
- // Handle id field for versioned collections
34
- // if "id" inside the query it should refer to the root table
35
- if (hasVersionsSuffix(slug) && 'id' in conditionObject) {
36
- // Replace id with ownerId and keep the same operator and value
37
- const idOperator = conditionObject.id;
38
- delete conditionObject.id;
39
- conditionObject.ownerId = idOperator;
40
- }
41
- // Handle versionId field for versioned collections
42
- // if "versionId" inside the query it should refer to the id of the version table (current)
43
- if (hasVersionsSuffix(slug) && 'versionId' in conditionObject) {
44
- // Replace id with ownerId and keep the same operator and value
45
- const idOperator = conditionObject.versionId;
46
- delete conditionObject.versionId;
47
- conditionObject.id = idOperator;
48
- }
49
- // Handle regular field conditions
50
- const [column, operatorObj] = Object.entries(conditionObject)[0];
51
- const [operator, rawValue] = Object.entries(operatorObj)[0];
52
- if (!isOperator(operator)) {
53
- throw new RimeError(RimeError.INVALID_DATA, operator + 'is not supported');
54
- }
55
- // get the correct Drizzle operator
56
- const fn = operators[operator];
57
- // Format compared value to support Date, Arrays,...
58
- const value = formatValue({ operator, value: rawValue });
59
- // Convert dot notation to double underscore
60
- // for fields included in groups or tabs
61
- const sqlColumn = column.replace(/\./g, '__');
38
+ conditionObject = normalizedForVersion(conditionObject, slug);
39
+ const {
40
+ // Get condition members
41
+ column, sqlColumn, fn, operator, rawValue, value } = getConditionMembers(conditionObject);
62
42
  // Handle hierarchy fields (_parent, _position) in versioned collections
63
- if (hasVersionsSuffix(slug) &&
64
- (sqlColumn === '_parent' || sqlColumn === '_position' || sqlColumn === '_path')) {
43
+ if (shouldHandleVersionedHierarchyFields(slug, sqlColumn)) {
65
44
  // Get the root table name by removing the '_versions' suffix
66
45
  const rootSlug = slug.replace('_versions', '');
67
46
  const rootTable = getTable(rootSlug);
@@ -82,154 +61,182 @@ export const buildWhereParam = ({ query, slug, db, locale, tables, configCtx })
82
61
  // Look for a relation field
83
62
  // Get document config
84
63
  const documentConfig = configCtx.getBySlug(slug);
85
- if (!documentConfig) {
86
- throw new Error(`${slug} not found (should never happen)`);
87
- }
88
- // Get the field config
89
- const fieldConfig = getFieldConfigByPath(column, documentConfig.fields.map((f) => f.compile()));
64
+ // Get the compiled fields for lookups
65
+ const compiledFields = documentConfig.fields.map((f) => f.compile());
66
+ let fieldConfig = getFieldConfigByPath(column, compiledFields);
67
+ // Track relation-property detection (e.g. attributes.author.name)
68
+ let matchedPrefix = column;
69
+ let relationPropertyPath = null;
70
+ let isRelationProperty = false;
71
+ // If the exact path isn't found, try progressively shorter prefixes to support
72
+ // relation property queries like `attributes.author.name` -> `attributes.author`
90
73
  if (!fieldConfig) {
91
- // @TODO handle relation props ex: author.email
92
- logger.warn(`the query contains the field "${column}", not found for ${documentConfig.slug} document`);
74
+ const parts = column.split('.');
75
+ for (let i = parts.length - 1; i > 0; i--) {
76
+ const prefix = parts.slice(0, i).join('.');
77
+ const candidate = getFieldConfigByPath(prefix, compiledFields);
78
+ if (candidate) {
79
+ fieldConfig = candidate;
80
+ // If prefix shorter than original column, record the property suffix
81
+ if (i < parts.length) {
82
+ matchedPrefix = prefix;
83
+ relationPropertyPath = parts.slice(i).join('.');
84
+ isRelationProperty = true;
85
+ }
86
+ break;
87
+ }
88
+ }
89
+ }
90
+ // If still not found or not a relation field, log warning and return false condition
91
+ if (!fieldConfig || !isRelationField(fieldConfig)) {
92
+ const message = `the query contains the field "${column}", not found for ${documentConfig.slug} document`;
93
+ logger.warn(message);
93
94
  // Return a condition that will always be false instead of returning false
94
95
  // This ensures no documents match when a non-existent field is queried
95
- return eq(table.id, '-1'); // No document will have ID = -1, so this will always be false
96
+ return eq(table.id, '-1');
96
97
  }
97
- // Not a relation
98
- if (!isRelationField(fieldConfig)) {
99
- logger.warn(`the query contains the field "${column}", not found for ${documentConfig.slug} document`);
100
- // Return a condition that will always be false
101
- return eq(table.id, '-1'); // No document will have ID = -1, so this will always be false
98
+ // Helper: normalize various input forms (rawValue, CSV string, or formatted array)
99
+ // into a unique array of values used for multi-valued relation comparisons.
100
+ const normalizeValues = (raw, formatted) => {
101
+ let out;
102
+ if (Array.isArray(raw))
103
+ out = raw;
104
+ else if (typeof raw === 'string' && raw.includes(','))
105
+ out = raw.split(',');
106
+ else if (Array.isArray(formatted))
107
+ out = formatted;
108
+ else
109
+ out = [formatted];
110
+ return Array.from(new Set(out));
111
+ };
112
+ // Handle relation property queries (e.g., attributes.author.name)
113
+ // by building a subquery on the related collection
114
+ const buildRelationPropertyCondition = () => {
115
+ const relatedSlug = fieldConfig.relationTo;
116
+ const relatedTable = getTable(relatedSlug);
117
+ // Build a where clause for the related collection using the same operator/value
118
+ const relatedWhere = { [relationPropertyPath]: { [operator]: rawValue } };
119
+ // Recursive call to buildWhereParam for the related collection
120
+ const relatedCondition = buildWhereParam({
121
+ query: { where: relatedWhere },
122
+ slug: relatedSlug,
123
+ db,
124
+ locale,
125
+ tables,
126
+ configCtx
127
+ });
128
+ if (!relatedCondition) {
129
+ // No documents in related collection match => no parent documents match
130
+ return eq(table.id, '-1');
131
+ }
132
+ // Subquery of related document ids that match the property condition
133
+ const matchingRelatedIds = db.select({ id: relatedTable.id }).from(relatedTable).where(relatedCondition);
134
+ const relsTable = getTable(`${slug}Rels`);
135
+ // Join relation rows to documents by matching the related id and the relation path
136
+ const ownersWithMatching = db
137
+ .select({ id: relsTable.ownerId })
138
+ .from(relsTable)
139
+ .where(and(inArray(relsTable[`${relatedSlug}Id`], matchingRelatedIds), eq(relsTable.path, matchedPrefix), ...(fieldConfig.localized ? [eq(relsTable.locale, locale)] : [])));
140
+ return inArray(table.id, ownersWithMatching);
141
+ };
142
+ // If this is a relation property query, delegate to the relation property handler
143
+ if (isRelationProperty && relationPropertyPath) {
144
+ return buildRelationPropertyCondition();
102
145
  }
103
- // Unsupported operator for multi-valued relations
146
+ // Handle direct relation field queries (e.g., attributes.author)
147
+ // only support a subset of operators for multi-valued relations
148
+ // Unsupported operator for multi-valued relations :
104
149
  const supportedRelationManyOperators = ['equals', 'not_equals', 'in_array', 'not_in_array'];
150
+ // Only enforce the restriction for direct relation field queries.
151
+ // If this is a relation property query (e.g. attributes.author.name) we allow
152
+ // any operator because those will be applied to the related collection.
105
153
  if (fieldConfig.many && !supportedRelationManyOperators.includes(operator)) {
106
- logger.warn(`the operator "${operator}" is not supported for multi-valued relation field "${column}" in ${documentConfig.slug} document`);
154
+ const unsupportedMessage = `the operator "${operator}" is not supported for multi-valued relation field "${column}" in ${documentConfig.slug} document`;
155
+ logger.warn(unsupportedMessage);
107
156
  // Return a condition that will always be false
108
- return eq(table.id, '-1'); // No document will have ID = -1, so this will always be false
157
+ return eq(table.id, '-1');
109
158
  }
110
- // Only compare with the relation ID for now
111
- // @TODO handle relation props ex: author.email
159
+ // Build relation condition
112
160
  const [to, localized] = [fieldConfig.relationTo, fieldConfig.localized];
113
- const relationTableName = `${slug}Rels`;
114
- const relationTable = getTable(relationTableName);
161
+ const relsTableName = `${slug}Rels`;
162
+ const relsTable = getTable(relsTableName);
163
+ // Helpers for building common relation-owner subqueries (operate on the relations table)
164
+ const buildOwnersWithTotalCount = (count) => db
165
+ .select({ id: relsTable.ownerId })
166
+ .from(relsTable)
167
+ .where(and(eq(relsTable.path, column), ...(localized ? [eq(relsTable.locale, locale)] : [])))
168
+ .groupBy(relsTable.ownerId)
169
+ .having(drizzleORM.eq(drizzleORM.count(relsTable.id), count));
170
+ // Owners that have total relation count equal to the number of provided values
171
+ const buildOwnersWithMatchingCount = (values) => db
172
+ .select({ id: relsTable.ownerId })
173
+ .from(relsTable)
174
+ .where(and(drizzleORM.inArray(relsTable[`${to}Id`], values), eq(relsTable.path, column), ...(localized ? [eq(relsTable.locale, locale)] : [])))
175
+ .groupBy(relsTable.ownerId)
176
+ .having(drizzleORM.eq(drizzleORM.count(relsTable.id), values.length));
177
+ // Owners that have at least one relation row NOT in the provided values
178
+ const buildOwnersWithNonMatching = (values) => db
179
+ .select({ id: relsTable.ownerId })
180
+ .from(relsTable)
181
+ .where(and(drizzleORM.notInArray(relsTable[`${to}Id`], values), eq(relsTable.path, column), ...(localized ? [eq(relsTable.locale, locale)] : [])))
182
+ .groupBy(relsTable.ownerId)
183
+ .having(drizzleORM.gt(drizzleORM.count(relsTable.id), 0));
184
+ // Owners that have any relation rows (to exclude docs with no relations)
185
+ const buildOwnersWithRelations = () => db
186
+ .select({ id: relsTable.ownerId })
187
+ .from(relsTable)
188
+ .where(and(eq(relsTable.path, column), ...(localized ? [eq(relsTable.locale, locale)] : [])))
189
+ .groupBy(relsTable.ownerId)
190
+ .having(drizzleORM.gt(drizzleORM.count(relsTable.id), 0));
115
191
  // Handle multi-valued relations specially when operator is `equals` to provide
116
192
  // strict equality semantics (the relation set must equal the provided value(s)).
117
193
  if (fieldConfig.many && operator === 'equals') {
118
- // Accept array inputs, repeated params, or CSV values for equality checks
119
- let values = (() => {
120
- if (Array.isArray(rawValue))
121
- return rawValue;
122
- if (typeof rawValue === 'string' && rawValue.includes(','))
123
- return rawValue.split(',');
124
- if (Array.isArray(value))
125
- return value;
126
- return [value];
127
- })();
128
- // Ensure values are unique
129
- values = Array.from(new Set(values));
194
+ // Accept various input forms and normalize to a unique array
195
+ const values = normalizeValues(rawValue, value);
130
196
  // Owners with total relation count equal to values.length
131
- const ownersWithTotalCount = db
132
- .select({ id: relationTable.ownerId })
133
- .from(relationTable)
134
- .where(and(eq(relationTable.path, column), ...(localized ? [eq(relationTable.locale, locale)] : [])))
135
- .groupBy(relationTable.ownerId)
136
- .having(drizzleORM.eq(drizzleORM.count(relationTable.id), values.length));
197
+ const ownersWithTotalCount = buildOwnersWithTotalCount(values.length);
137
198
  // Owners with matching relations count equal to values.length
138
- const ownersWithMatchingCount = db
139
- .select({ id: relationTable.ownerId })
140
- .from(relationTable)
141
- .where(and(drizzleORM.inArray(relationTable[`${to}Id`], values), eq(relationTable.path, column), ...(localized ? [eq(relationTable.locale, locale)] : [])))
142
- .groupBy(relationTable.ownerId)
143
- .having(drizzleORM.eq(drizzleORM.count(relationTable.id), values.length));
199
+ const ownersWithMatchingCount = buildOwnersWithMatchingCount(values);
144
200
  return and(inArray(table.id, ownersWithTotalCount), inArray(table.id, ownersWithMatchingCount));
145
201
  }
146
202
  // For multi-valued relations, allow `in_array` to act as a subset check:
147
203
  // The provided values must contain ALL relation values of the document.
148
204
  if (fieldConfig.many && operator === 'in_array') {
149
- // Accept array inputs, repeated params, or CSV values for in_array checks
150
- let values = (() => {
151
- if (Array.isArray(rawValue))
152
- return rawValue;
153
- if (typeof rawValue === 'string' && rawValue.includes(','))
154
- return rawValue.split(',');
155
- if (Array.isArray(value))
156
- return value;
157
- return [value];
158
- })();
159
- // Ensure values are unique
160
- values = Array.from(new Set(values));
205
+ // Accept various input forms and normalize to a unique array
206
+ const values = normalizeValues(rawValue, value);
161
207
  // Owners that have at least one relation row not included in the provided set
162
- const ownersWithNonMatching = db
163
- .select({ id: relationTable.ownerId })
164
- .from(relationTable)
165
- .where(and(drizzleORM.notInArray(relationTable[`${to}Id`], values), eq(relationTable.path, column), ...(localized ? [eq(relationTable.locale, locale)] : [])))
166
- .groupBy(relationTable.ownerId)
167
- .having(drizzleORM.gt(drizzleORM.count(relationTable.id), 0));
208
+ const ownersWithNonMatching = buildOwnersWithNonMatching(values);
168
209
  // Owners that have any relation rows (to exclude docs with no relations)
169
- const ownersWithRelations = db
170
- .select({ id: relationTable.ownerId })
171
- .from(relationTable)
172
- .where(and(eq(relationTable.path, column), ...(localized ? [eq(relationTable.locale, locale)] : [])))
173
- .groupBy(relationTable.ownerId)
174
- .having(drizzleORM.gt(drizzleORM.count(relationTable.id), 0));
210
+ const ownersWithRelations = buildOwnersWithRelations();
175
211
  // Match documents that have relations and do NOT have any non-matching relation rows
176
212
  return and(drizzleORM.notInArray(table.id, ownersWithNonMatching), inArray(table.id, ownersWithRelations));
177
213
  }
178
214
  // For multi-valued relations, `not_in_array` should match documents where the provided
179
215
  // set does NOT contain all of the document's relation values (inverse of `in_array`).
180
216
  if (fieldConfig.many && operator === 'not_in_array') {
181
- let values = (() => {
182
- if (Array.isArray(rawValue))
183
- return rawValue;
184
- if (typeof rawValue === 'string' && rawValue.includes(','))
185
- return rawValue.split(',');
186
- if (Array.isArray(value))
187
- return value;
188
- return [value];
189
- })();
190
- values = Array.from(new Set(values));
191
- const ownersWithNonMatching = db
192
- .select({ id: relationTable.ownerId })
193
- .from(relationTable)
194
- .where(and(drizzleORM.notInArray(relationTable[`${to}Id`], values), eq(relationTable.path, column), ...(localized ? [eq(relationTable.locale, locale)] : [])))
195
- .groupBy(relationTable.ownerId)
196
- .having(drizzleORM.gt(drizzleORM.count(relationTable.id), 0));
217
+ // Accept various input forms and normalize to a unique array
218
+ const values = normalizeValues(rawValue, value);
219
+ const ownersWithNonMatching = buildOwnersWithNonMatching(values);
197
220
  return inArray(table.id, ownersWithNonMatching);
198
221
  }
199
222
  // For multi-valued relations, `not_equals` is the inverse of `equals` (exact-set inequality)
200
223
  if (fieldConfig.many && operator === 'not_equals') {
201
- let values = (() => {
202
- if (Array.isArray(rawValue))
203
- return rawValue;
204
- if (typeof rawValue === 'string' && rawValue.includes(','))
205
- return rawValue.split(',');
206
- if (Array.isArray(value))
207
- return value;
208
- return [value];
209
- })();
210
- values = Array.from(new Set(values));
211
- const ownersWithTotalCount = db
212
- .select({ id: relationTable.ownerId })
213
- .from(relationTable)
214
- .where(and(eq(relationTable.path, column), ...(localized ? [eq(relationTable.locale, locale)] : [])))
215
- .groupBy(relationTable.ownerId)
216
- .having(drizzleORM.eq(drizzleORM.count(relationTable.id), values.length));
217
- const ownersWithMatchingCount = db
218
- .select({ id: relationTable.ownerId })
219
- .from(relationTable)
220
- .where(and(inArray(relationTable[`${to}Id`], values), eq(relationTable.path, column), ...(localized ? [eq(relationTable.locale, locale)] : [])))
221
- .groupBy(relationTable.ownerId)
222
- .having(drizzleORM.eq(drizzleORM.count(relationTable.id), values.length));
224
+ // Accept various input forms and normalize to a unique array
225
+ const values = normalizeValues(rawValue, value);
226
+ const ownersWithTotalCount = buildOwnersWithTotalCount(values.length);
227
+ const ownersWithMatchingCount = buildOwnersWithMatchingCount(values);
223
228
  return or(drizzleORM.notInArray(table.id, ownersWithTotalCount), drizzleORM.notInArray(table.id, ownersWithMatchingCount));
224
229
  }
230
+ // Handle single-valued relations and other operators
225
231
  // Default behavior (membership checks with in_array etc.)
226
- const conditions = [eq(relationTable.path, column), fn(relationTable[`${to}Id`], value)];
227
- if (localized) {
228
- conditions.push(eq(relationTable.locale, locale));
229
- }
232
+ const conditions = [
233
+ eq(relsTable.path, column),
234
+ fn(relsTable[`${to}Id`], value),
235
+ ...(localized ? [eq(relsTable.locale, locale)] : [])
236
+ ];
230
237
  return inArray(table.id, db
231
- .select({ id: relationTable.ownerId })
232
- .from(relationTable)
238
+ .select({ id: relsTable.ownerId })
239
+ .from(relsTable)
233
240
  .where(and(...conditions)));
234
241
  };
235
242
  return buildCondition(query.where);
@@ -252,6 +259,7 @@ const operators = {
252
259
  greater_than: drizzleORM.gt
253
260
  };
254
261
  const isOperator = (str) => Object.keys(operators).includes(str);
262
+ // Format value based on operator and type
255
263
  const formatValue = ({ operator, value }) => {
256
264
  switch (true) {
257
265
  case typeof value === 'string' &&
@@ -269,3 +277,43 @@ const formatValue = ({ operator, value }) => {
269
277
  return value;
270
278
  }
271
279
  };
280
+ // Extract column, operator, and raw value from condition object
281
+ function getConditionMembers(obj) {
282
+ const [column, operatorObj] = Object.entries(obj)[0];
283
+ const [operator, rawValue] = Object.entries(operatorObj)[0];
284
+ if (!isOperator(operator)) {
285
+ throw new RimeError(RimeError.INVALID_DATA, operator + 'is not supported');
286
+ }
287
+ // get the correct Drizzle operator
288
+ const fn = operators[operator];
289
+ // Convert dot notation to double underscore
290
+ // for fields included in groups or tabs
291
+ const sqlColumn = column.replace(/\./g, '__');
292
+ // Format compared value to support Date, Arrays,...
293
+ const value = formatValue({ operator, value: rawValue });
294
+ return { column, sqlColumn, fn, operator, rawValue, value };
295
+ }
296
+ // Determine if we should handle versioned hierarchy fields
297
+ function shouldHandleVersionedHierarchyFields(slug, sqlColumn) {
298
+ return hasVersionsSuffix(slug) && (sqlColumn === '_parent' || sqlColumn === '_position' || sqlColumn === '_path');
299
+ }
300
+ // Normalize condition object for versioned collections
301
+ function normalizedForVersion(conditionObject, slug) {
302
+ // Handle id field for versioned collections
303
+ // if "id" inside the query it should refer to the root table
304
+ if (hasVersionsSuffix(slug) && 'id' in conditionObject) {
305
+ // Replace id with ownerId and keep the same operator and value
306
+ const idOperator = conditionObject.id;
307
+ delete conditionObject.id;
308
+ conditionObject.ownerId = idOperator;
309
+ }
310
+ // Handle versionId field for versioned collections
311
+ // if "versionId" inside the query it should refer to the id of the version table (current)
312
+ if (hasVersionsSuffix(slug) && 'versionId' in conditionObject) {
313
+ // Replace id with ownerId and keep the same operator and value
314
+ const idOperator = conditionObject.versionId;
315
+ delete conditionObject.versionId;
316
+ conditionObject.id = idOperator;
317
+ }
318
+ return conditionObject;
319
+ }
@@ -4,8 +4,8 @@ import type { DateField } from '../../../fields/date/index.js';
4
4
  import type { EmailField } from '../../../fields/email/index.js';
5
5
  import type { SlugField } from '../../../fields/slug/index.js';
6
6
  import type { TextField } from '../../../fields/text/index.js';
7
- import type { Field, FormField } from '../../../fields/types.js';
8
- export declare const hasMaybeTitle: (field: Field) => field is TextField | DateField | SlugField | EmailField;
7
+ import type { Field, FormField, RichTextField } from '../../../fields/types.js';
8
+ export declare const hasMaybeTitle: (field: Field) => field is TextField | DateField | SlugField | EmailField | RichTextField;
9
9
  interface TitleFieldResult {
10
10
  field: FormFieldBuilder<FormField>;
11
11
  path: string;
@@ -1,7 +1,7 @@
1
1
  import { FormFieldBuilder } from '../../fields/builders/form-field-builder.js';
2
2
  import { isGroupField } from '../../../fields/group/index.js';
3
3
  import { TabsBuilder } from '../../../fields/tabs/index.js';
4
- export const hasMaybeTitle = (field) => ['text', 'date', 'slug', 'email'].includes(field.type);
4
+ export const hasMaybeTitle = (field) => ['text', 'date', 'slug', 'email', 'richText'].includes(field.type);
5
5
  export function findTitleField(fields = [], basePath = '') {
6
6
  for (const field of fields) {
7
7
  // Direct check for isTitle
@@ -1,4 +1,5 @@
1
- import { getValueAtPath } from '../../../../util/object.js';
1
+ import { getValueAtPath, isObjectLiteral } from '../../../../util/object.js';
2
+ import { richTextJSONToText } from '../../../../fields/rich-text/client.js';
2
3
  import { Hooks } from '../index.server.js';
3
4
  export const setDocumentTitle = Hooks.beforeRead(async (args) => {
4
5
  const config = args.config;
@@ -7,8 +8,20 @@ export const setDocumentTitle = Hooks.beforeRead(async (args) => {
7
8
  const hasSelect = Array.isArray(paramSelect) && paramSelect.length;
8
9
  const shouldSetTitle = !doc.title && (!hasSelect || (hasSelect && paramSelect.includes('title')));
9
10
  if (shouldSetTitle) {
11
+ function computeTitleFromValue(value) {
12
+ // Handle rich text value
13
+ if (isObjectLiteral(value) && 'content' in value) {
14
+ return richTextJSONToText(value);
15
+ }
16
+ if (typeof value === 'string') {
17
+ return value;
18
+ }
19
+ return doc.id;
20
+ }
21
+ const titleRaw = getValueAtPath(config.asTitle, doc);
22
+ const title = computeTitleFromValue(titleRaw);
10
23
  doc = {
11
- title: getValueAtPath(config.asTitle, doc),
24
+ title,
12
25
  ...doc
13
26
  };
14
27
  }
@@ -17,7 +17,7 @@
17
17
  let { value }: Props = $props();
18
18
  let displayCount = $derived(value && value.length > 1);
19
19
 
20
- const APIProxy = getAPIProxyContext(API_PROXY.DOCUMENT);
20
+ const APIProxy = getAPIProxyContext(API_PROXY.ROOT);
21
21
 
22
22
  let APIUrl = $derived.by(() => {
23
23
  if (value && value.length && value[0].documentId) {
@@ -33,7 +33,7 @@
33
33
  });
34
34
  </script>
35
35
 
36
- <span>
36
+ <span class="rz-relation-cell">
37
37
  {#if displayCount}
38
38
  {value.length} items
39
39
  {:else if ressource?.data?.doc}
@@ -44,15 +44,21 @@
44
44
  />
45
45
  {ressource.data.doc.title}
46
46
  {:else}
47
- {ressource.data.doc.title}
47
+ <span class="rz-relation-cell__title">{ressource.data.doc.title}</span>
48
48
  {/if}
49
49
  {/if}
50
50
  </span>
51
51
 
52
52
  <style>
53
- span {
53
+ .rz-relation-cell {
54
54
  display: flex;
55
55
  gap: var(--rz-size-2);
56
56
  align-items: center;
57
57
  }
58
+ .rz-relation-cell__title {
59
+ overflow: hidden;
60
+ display: -webkit-box;
61
+ -webkit-box-orient: vertical;
62
+ -webkit-line-clamp: 1;
63
+ }
58
64
  </style>
@@ -83,7 +83,7 @@
83
83
  >
84
84
  {#each selectedItems as item (item.documentId)}
85
85
  <Tag onRemove={() => removeValue(item.documentId)} {readOnly}>
86
- {item.label}
86
+ <a href={item.editUrl}>{item.label}</a>
87
87
  </Tag>
88
88
  {/each}
89
89
 
@@ -9,6 +9,7 @@ export declare class RichTextFieldBuilder extends FormFieldBuilder<RichTextField
9
9
  get cell(): import("svelte").Component<{
10
10
  value: string;
11
11
  }, {}, "">;
12
+ isTitle(): this;
12
13
  /**
13
14
  * Sets a custom TipTap editor configuration for the rich text field.
14
15
  *
@@ -44,6 +45,7 @@ type RichTextContent = {
44
45
  };
45
46
  export type RichTextField = FormField & {
46
47
  type: 'richText';
48
+ isTitle?: true;
47
49
  features?: Array<RichTextFeature>;
48
50
  defaultValue?: RichTextContent | DefaultValueFn<RichTextContent>;
49
51
  };