@riducms/admin 0.3.0 → 0.5.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/package.json CHANGED
@@ -21,19 +21,21 @@
21
21
  "@iconify/json": "^2.2.509",
22
22
  "@internationalized/date": "^3.12.3",
23
23
  "@lezer/highlight": "^1.2.3",
24
- "@riducms/plugin": "0.3.0",
25
- "@riducms/protocol": "0.3.0",
26
- "@riducms/sdk": "0.3.0",
27
- "@riducms/translations": "0.3.0",
28
- "@riducms/ui": "0.3.0",
24
+ "@riducms/plugin": "0.5.0",
25
+ "@riducms/protocol": "0.5.0",
26
+ "@riducms/sdk": "0.5.0",
27
+ "@riducms/translations": "0.5.0",
28
+ "@riducms/ui": "0.5.0",
29
+ "@twinkleplop/bash": "0.1.5",
30
+ "@twinkleplop/go": "0.1.5",
31
+ "@twinkleplop/typescript": "0.1.5",
29
32
  "bits-ui": "^2.18.1",
30
- "shiki": "4.4.3",
31
33
  "svelte-sonner": "^1.1.1"
32
34
  },
33
35
  "description": "Framework-owned Svelte 5 authoring application embedded by Ridu servers.",
34
36
  "devDependencies": {
35
37
  "@hvniel/lexical-svelte": "^0.1.1",
36
- "@riducms/build": "0.3.0",
38
+ "@riducms/build": "0.5.0",
37
39
  "svelte": "^5.57.0"
38
40
  },
39
41
  "exports": {
@@ -72,5 +74,5 @@
72
74
  "test:unit": "bun test tests"
73
75
  },
74
76
  "type": "module",
75
- "version": "0.3.0"
77
+ "version": "0.5.0"
76
78
  }
@@ -66,6 +66,16 @@ export function submissionFormValues(
66
66
  return submissionRecord(fields, input, "", include);
67
67
  }
68
68
 
69
+ /** Project an update onto caller edits, retaining row membership and stable identities. */
70
+ export function changedFormValues(
71
+ fields: readonly SchemaField[],
72
+ input: FormValues,
73
+ original: FormValues,
74
+ isInherited?: (path: string) => boolean
75
+ ): FormValues {
76
+ return submissionRecord(fields, input, "", () => true, original, isInherited);
77
+ }
78
+
69
79
  /** Initialize a new record, retaining supplied values and container metadata. */
70
80
  export function initialFormValues(
71
81
  fields: readonly SchemaField[],
@@ -100,9 +110,12 @@ function submissionRecord(
100
110
  fields: readonly SchemaField[],
101
111
  input: unknown,
102
112
  prefix: string,
103
- include: (path: string, canonicalPath: string) => boolean
113
+ include: (path: string, canonicalPath: string) => boolean,
114
+ original?: unknown,
115
+ isInherited?: (path: string) => boolean
104
116
  ): FormValues {
105
117
  const values = isRecord(input) ? input : {};
118
+ const before = isRecord(original) ? original : {};
106
119
  const submitted: FormValues = {};
107
120
  for (const field of fields) {
108
121
  const path = joinFormPath(prefix, field.name);
@@ -113,23 +126,47 @@ function submissionRecord(
113
126
  )
114
127
  continue;
115
128
  const value = values[field.name];
116
- if (field.type === "group") {
117
- submitted[field.name] = submissionRecord(field.nested?.fields ?? [], value, path, include);
118
- } else if (field.type === "array" && Array.isArray(value)) {
119
- submitted[field.name] = value.map((row, index) =>
120
- submissionRecord(field.nested?.fields ?? [], row, `${path}.${index}`, include)
129
+ const displayedPrevious = before[field.name];
130
+ if (Object.hasOwn(before, field.name) && deepEqual(value, displayedPrevious)) continue;
131
+ // An edited fallback establishes a new locale value, without retained children to merge.
132
+ const previous = isInherited?.(path) === true ? undefined : displayedPrevious;
133
+
134
+ if (field.type === "group" && isRecord(value)) {
135
+ submitted[field.name] = submissionRecord(
136
+ field.nested?.fields ?? [],
137
+ value,
138
+ path,
139
+ include,
140
+ previous,
141
+ isInherited
121
142
  );
122
- } else if (field.type === "blocks" && Array.isArray(value)) {
143
+ } else if ((field.type === "array" || field.type === "blocks") && Array.isArray(value)) {
144
+ const previousRows = new Map(
145
+ (Array.isArray(previous) ? previous : []).flatMap((row) =>
146
+ isRecord(row) && typeof row._key === "string" ? [[row._key, row] as const] : []
147
+ )
148
+ );
149
+ const blocks = resolveBlockTypes(field.blocks);
123
150
  submitted[field.name] = value.map((row, index) => {
124
- if (!isRecord(row) || typeof row.blockType !== "string") return cloneFormValue(row);
125
- const block = resolveBlockTypes(field.blocks).find(
126
- (candidate) => candidate.slug === row.blockType
151
+ if (!isRecord(row)) return cloneFormValue(row);
152
+ const children =
153
+ field.type === "array"
154
+ ? (field.nested?.fields ?? [])
155
+ : blocks.find((block) => block.slug === row.blockType)?.fields;
156
+ if (children === undefined) return cloneFormValue(row);
157
+
158
+ const prior = typeof row._key === "string" ? previousRows.get(row._key) : undefined;
159
+ return submissionRecord(
160
+ children,
161
+ row,
162
+ `${path}.${index}`,
163
+ include,
164
+ field.type === "blocks" && prior?.blockType !== row.blockType ? undefined : prior,
165
+ isInherited
127
166
  );
128
- return block === undefined
129
- ? cloneFormValue(row)
130
- : submissionRecord(block.fields, row, `${path}.${index}`, include);
131
167
  });
132
168
  } else if (field.type === "plugin" && field.plugin?.embeddedTrees !== undefined) {
169
+ const priorOccurrences = embeddedOccurrences(field, previous, path).occurrences;
133
170
  submitted[field.name] = transformEmbeddedPayloads(
134
171
  field,
135
172
  cloneFormValue(value),
@@ -139,7 +176,17 @@ function submissionRecord(
139
176
  occurrence.block.fields,
140
177
  occurrence.payload,
141
178
  occurrence.path,
142
- include
179
+ include,
180
+ occurrence.identity === undefined
181
+ ? undefined
182
+ : priorOccurrences.find(
183
+ (prior) =>
184
+ prior.tree.key === occurrence.tree.key &&
185
+ prior.case.tagValue === occurrence.case.tagValue &&
186
+ prior.block.slug === occurrence.block.slug &&
187
+ prior.identity === occurrence.identity
188
+ )?.payload,
189
+ isInherited
143
190
  ),
144
191
  [occurrence.case.discriminator]: occurrence.block.slug,
145
192
  ...(occurrence.identity === undefined
@@ -455,7 +455,7 @@ export class AdminRuntime {
455
455
  const accessAuthoritative = await this.refreshAccess(this.contentLocale);
456
456
  if (!accessAuthoritative || this.session === undefined || this.adminAccessAllowed) return;
457
457
  try {
458
- await this.client.logout();
458
+ await this.client.auth.logout();
459
459
  } catch {
460
460
  // The local session is still denied even if server cleanup fails.
461
461
  }
@@ -471,21 +471,18 @@ export class AdminRuntime {
471
471
  if (request !== this.#manifestRequest) return;
472
472
  const admin = this.#installManifest(manifest);
473
473
  if (admin !== undefined) {
474
- const bootstrap = await this.client.authBootstrap(admin.userCollectionSlug);
474
+ const bootstrap = await this.client.auth.bootstrap({
475
+ collection: admin.userCollectionSlug,
476
+ });
475
477
  if (request !== this.#manifestRequest) return;
476
478
  this.authBootstrapAvailable = bootstrap.available;
477
479
  if (bootstrap.available) {
478
480
  this.session = undefined;
479
481
  } else {
480
- try {
481
- const session = await this.client.session();
482
- if (request !== this.#manifestRequest) return;
483
- this.session = session.collection === admin.userCollectionSlug ? session : undefined;
484
- } catch (error) {
485
- if (request !== this.#manifestRequest) return;
486
- if (!(error instanceof RiduError) || error.code !== "access_denied") throw error;
487
- this.session = undefined;
488
- }
482
+ // getSession resolves null without a session and rejects on an outage.
483
+ const session = await this.client.auth.getSession();
484
+ if (request !== this.#manifestRequest) return;
485
+ this.session = session?.collection === admin.userCollectionSlug ? session : undefined;
489
486
  }
490
487
  } else {
491
488
  this.session = undefined;
@@ -513,7 +510,9 @@ export class AdminRuntime {
513
510
  this.session = undefined;
514
511
  }
515
512
  if (admin !== undefined && this.session === undefined) {
516
- const bootstrap = await this.client.authBootstrap(admin.userCollectionSlug);
513
+ const bootstrap = await this.client.auth.bootstrap({
514
+ collection: admin.userCollectionSlug,
515
+ });
517
516
  if (request !== this.#manifestRequest) return;
518
517
  this.authBootstrapAvailable = bootstrap.available;
519
518
  } else {
@@ -336,7 +336,7 @@
336
336
  const request = beginProfileRequest();
337
337
 
338
338
  try {
339
- await runtime.client.logout({ signal: request.signal });
339
+ await runtime.client.auth.logout({ signal: request.signal });
340
340
  if (!ownsProfileRequest(request, owner)) return;
341
341
 
342
342
  await runtime.hardNavigate(adminRoutePatterns.login);
@@ -47,8 +47,10 @@ export class AccountSecurityController {
47
47
  this.error = undefined;
48
48
  try {
49
49
  const [sessions, apiKeys] = await Promise.all([
50
- this.client.sessions({ signal: request.signal }),
51
- this.apiKeysEnabled ? this.client.apiKeys({ signal: request.signal }) : Promise.resolve([]),
50
+ this.client.auth.sessions({ signal: request.signal }),
51
+ this.apiKeysEnabled
52
+ ? this.client.auth.apiKeys({ signal: request.signal })
53
+ : Promise.resolve([]),
52
54
  ]);
53
55
  if (request.signal.aborted || generation !== this.#generation) return;
54
56
  this.sessions = sessions;
@@ -64,7 +66,7 @@ export class AccountSecurityController {
64
66
  revokeSession = async (id: string) => {
65
67
  return this.#run(
66
68
  "session:" + id,
67
- (signal) => this.client.revokeSession(id, { signal }),
69
+ (signal) => this.client.auth.revokeSession(id, { signal }),
68
70
  () => (this.sessions = this.sessions.filter((session) => session.id !== id))
69
71
  );
70
72
  };
@@ -72,7 +74,7 @@ export class AccountSecurityController {
72
74
  logoutAll = async () => {
73
75
  return this.#run(
74
76
  "logout-all",
75
- (signal) => this.client.logoutAll({ signal }),
77
+ (signal) => this.client.auth.logoutAll({ signal }),
76
78
  () => (this.sessions = [])
77
79
  );
78
80
  };
@@ -80,7 +82,7 @@ export class AccountSecurityController {
80
82
  createAPIKey = async (name: string, expiresAt?: string) => {
81
83
  return this.#run(
82
84
  "api-key:create",
83
- (signal) => this.client.createAPIKey({ name, expiresAt }, { signal }),
85
+ (signal) => this.client.auth.createAPIKey({ name, expiresAt }, { signal }),
84
86
  (created) => {
85
87
  this.createdAPIKey = created;
86
88
  this.apiKeys = [
@@ -103,7 +105,7 @@ export class AccountSecurityController {
103
105
  revokeAPIKey = async (id: string) => {
104
106
  return this.#run(
105
107
  "api-key:" + id,
106
- (signal) => this.client.revokeAPIKey(id, { signal }),
108
+ (signal) => this.client.auth.revokeAPIKey(id, { signal }),
107
109
  () => {
108
110
  this.apiKeys = this.apiKeys.filter((key) => key.id !== id);
109
111
  if (this.createdAPIKey?.id === id) this.createdAPIKey = undefined;
@@ -113,7 +115,7 @@ export class AccountSecurityController {
113
115
 
114
116
  changePassword = async (currentPassword: string, password: string) => {
115
117
  return this.#run("password", (signal) =>
116
- this.client.changePassword(currentPassword, password, { signal })
118
+ this.client.auth.changePassword({ currentPassword, password }, { signal })
117
119
  );
118
120
  };
119
121
 
@@ -194,7 +194,7 @@
194
194
  hostRequest = request;
195
195
 
196
196
  try {
197
- await runtime.client.logout({ signal: request.signal });
197
+ await runtime.client.auth.logout({ signal: request.signal });
198
198
  if (request.signal.aborted || runtime.session?.id !== owner) return;
199
199
 
200
200
  await runtime.hardNavigate(adminRoutePatterns.login);
@@ -1,44 +1,43 @@
1
1
  <script lang="ts" module>
2
- import { createHighlighterCoreSync } from "shiki/core";
3
- import { createJavaScriptRegexEngine } from "shiki/engine/javascript";
4
- import typescript from "shiki/langs/typescript.mjs";
5
- import shell from "shiki/langs/shellscript.mjs";
6
- import go from "shiki/langs/go.mjs";
7
- import { riduCodeTheme } from "@riducms/ui/code-theme";
2
+ import { tokenize as tokenizeTypeScript } from "@twinkleplop/typescript";
3
+ import { tokenize as tokenizeBash } from "@twinkleplop/bash";
4
+ import { tokenize as tokenizeGo } from "@twinkleplop/go";
8
5
  import "@fontsource-variable/martian-mono";
6
+ import type { ReferenceLanguage } from "@admin/features/api-reference/api-reference-examples";
7
+
8
+ // Keep the grammars in the drawer's lazy chunk and reuse their tokenizers across examples.
9
+ const tokenizers = {
10
+ typescript: tokenizeTypeScript(),
11
+ curl: tokenizeBash(),
12
+ go: tokenizeGo(),
13
+ };
9
14
 
10
- // This module belongs to the drawer's lazy chunk. Reuse one tokenizer across examples.
11
- const highlighter = createHighlighterCoreSync({
12
- engine: createJavaScriptRegexEngine(),
13
- themes: [riduCodeTheme],
14
- langs: [typescript, shell, go],
15
- });
15
+ function highlightedTokens(code: string, language: ReferenceLanguage) {
16
+ const { tokens, token_types } = tokenizers[language](code);
17
+ const parts: { content: string; type?: string }[] = [];
18
+ let offset = 0;
19
+
20
+ for (let index = 0; index < tokens.length; index += 3) {
21
+ const start = tokens[index + 1];
22
+ const end = tokens[index + 2];
23
+ if (start > offset) parts.push({ content: code.slice(offset, start) });
24
+ parts.push({ content: code.slice(start, end), type: token_types[tokens[index]] });
25
+ offset = end;
26
+ }
27
+
28
+ if (offset < code.length) parts.push({ content: code.slice(offset) });
29
+ return parts;
30
+ }
16
31
  </script>
17
32
 
18
33
  <script lang="ts">
19
- import type { ReferenceLanguage } from "@admin/features/api-reference/api-reference-examples";
20
-
21
34
  let { code, language }: { code: string; language: ReferenceLanguage } = $props();
22
-
23
- const highlighted = $derived(
24
- highlighter.codeToTokens(code, {
25
- lang: language === "curl" ? "shellscript" : language,
26
- theme: riduCodeTheme.name,
27
- })
28
- );
35
+ const highlighted = $derived(highlightedTokens(code, language));
29
36
  </script>
30
37
 
31
38
  <!-- svelte-ignore a11y_no_noninteractive_tabindex (Scrollable examples need a keyboard scroll target.) -->
32
- <pre
33
- class="ridu-api-reference__code"
34
- dir="ltr"
35
- tabindex="0"
36
- style:--code-block-foreground={highlighted.fg}><code>{#each highlighted.tokens as line, index}{#if index > 0}{"\n"}{/if}{#each line as token}<span
37
- style:color={token.color}
38
- style:font-style={token.fontStyle && token.fontStyle & 1 ? "italic" : undefined}
39
- style:font-weight={token.fontStyle && token.fontStyle & 2
40
- ? "700"
41
- : undefined}>{token.content}</span>{/each}{/each}</code></pre>
39
+ <pre class="ridu-api-reference__code" dir="ltr" tabindex="0"><code>{#each highlighted as token}<span
40
+ class={token.type}>{token.content}</span>{/each}</code></pre>
42
41
 
43
42
  <style lang="scss">
44
43
  @layer ridu.components {
@@ -47,7 +46,8 @@
47
46
  padding: 18px 20px;
48
47
  max-height: 430px;
49
48
  overflow: auto;
50
- color: var(--code-block-foreground);
49
+ background: #0d0a0c;
50
+ color: #e9e6e1;
51
51
  border: 1px solid var(--border);
52
52
  border-radius: var(--radius-sm);
53
53
  font-family: "Martian Mono Variable", "Martian Mono", ui-monospace, monospace;
@@ -59,6 +59,56 @@
59
59
  code {
60
60
  font: inherit;
61
61
  }
62
+
63
+ :global(.comment) {
64
+ color: #7d7a8c;
65
+ font-style: italic;
66
+ }
67
+
68
+ :global(.keyword),
69
+ :global(.decorator) {
70
+ color: #ff7eb6;
71
+ }
72
+
73
+ :global(.string),
74
+ :global(.string_escape),
75
+ :global(.template),
76
+ :global(.regex) {
77
+ color: #b5e48c;
78
+ }
79
+
80
+ :global(.number),
81
+ :global(.boolean),
82
+ :global(.constant),
83
+ :global(.property) {
84
+ color: #c4a5ff;
85
+ }
86
+
87
+ :global(.function) {
88
+ color: #8fb4ff;
89
+ font-weight: 700;
90
+ }
91
+
92
+ :global(.type),
93
+ :global(.class_name) {
94
+ color: #7fdbec;
95
+ font-style: italic;
96
+ }
97
+
98
+ :global(.parameter) {
99
+ color: #ffb977;
100
+ font-style: italic;
101
+ }
102
+
103
+ :global(.builtin),
104
+ :global(.namespace) {
105
+ color: #8fb4ff;
106
+ }
107
+
108
+ :global(.punctuation),
109
+ :global(.operator) {
110
+ color: #80807e;
111
+ }
62
112
  }
63
113
  }
64
114
  </style>
@@ -127,7 +127,7 @@ export function referenceCode(
127
127
  break;
128
128
  case "create":
129
129
  if (request.authCreate)
130
- call = `const doc = await ridu.createAuthUser(${slug}, ${data}, "REPLACE_WITH_A_PASSWORD");`;
130
+ call = `const doc = await ridu.auth.createUser({\n collection: ${slug},\n data: ${data},\n password: "REPLACE_WITH_A_PASSWORD",\n});`;
131
131
  else if (request.uploadCreate)
132
132
  call = `async function createUpload(file: File) {\n const data: RiduConfig["collections"][${slug}]["create"] = ${data};\n return ridu.upload(${slug}, file, { data });\n}`;
133
133
  else call = `const doc = await ridu.create(${slug}, ${data});`;
@@ -77,9 +77,10 @@ export class CreateFirstUserController {
77
77
  let accountCreated = false;
78
78
  try {
79
79
  await this.form.submit(this.fields, true, (values) =>
80
- this.options.runtime.client.createAuthUser(collection.slug, values, this.password, {
81
- signal: request.signal,
82
- })
80
+ this.options.runtime.client.auth.createUser(
81
+ { collection: collection.slug, data: values, password: this.password },
82
+ { signal: request.signal }
83
+ )
83
84
  );
84
85
  accountCreated = true;
85
86
  if (!this.#current(generation, request)) return undefined;
@@ -88,9 +89,8 @@ export class CreateFirstUserController {
88
89
  if (typeof identity !== "string" || identity.trim() === "") {
89
90
  throw new Error(this.options.runtime.i18n.t("auth:firstAccountIdentityUnavailable"));
90
91
  }
91
- await this.options.runtime.client.login(
92
- collection.slug,
93
- { email: identity, password: this.password },
92
+ await this.options.runtime.client.auth.login(
93
+ { collection: collection.slug, email: identity, password: this.password },
94
94
  { signal: request.signal }
95
95
  );
96
96
  if (!this.#current(generation, request)) return undefined;
@@ -101,7 +101,7 @@ export class CreateFirstUserController {
101
101
  this.options.runtime.collectionOperations[collection.slug]?.admin !== true
102
102
  ) {
103
103
  try {
104
- await this.options.runtime.client.logout({ signal: request.signal });
104
+ await this.options.runtime.client.auth.logout({ signal: request.signal });
105
105
  } catch {
106
106
  // The setup route remains closed even if cookie cleanup fails.
107
107
  }
@@ -133,9 +133,10 @@ export class CreateFirstUserController {
133
133
  }
134
134
 
135
135
  try {
136
- const bootstrap = await this.options.runtime.client.authBootstrap(collection.slug, {
137
- signal: request.signal,
138
- });
136
+ const bootstrap = await this.options.runtime.client.auth.bootstrap(
137
+ { collection: collection.slug },
138
+ { signal: request.signal }
139
+ );
139
140
  if (!this.#current(generation, request)) return undefined;
140
141
  if (!bootstrap.available) {
141
142
  await this.#continueToLogin("completed");
@@ -25,7 +25,7 @@
25
25
  error = undefined;
26
26
 
27
27
  try {
28
- await runtime.client.requestPasswordReset(authCollection.slug, email);
28
+ await runtime.client.auth.requestPasswordReset({ collection: authCollection.slug, email });
29
29
  sent = true;
30
30
  } catch (cause) {
31
31
  error =
@@ -50,7 +50,7 @@
50
50
  throw new Error(runtime.i18n.t("auth:configuredCollectionUnavailable"));
51
51
  }
52
52
 
53
- await runtime.client.login(authCollection.slug, credentials);
53
+ await runtime.client.auth.login({ collection: authCollection.slug, ...credentials });
54
54
  const destinationLocale = runtime.contentLocaleForPath(postLoginPath);
55
55
  const destinationAccessAuthoritative = await runtime.refreshAccess(
56
56
  destinationLocale ?? runtime.contentLocale
@@ -60,7 +60,7 @@
60
60
  runtime.collectionOperations[authCollection.slug]?.admin !== true
61
61
  ) {
62
62
  try {
63
- await runtime.client.logout();
63
+ await runtime.client.auth.logout();
64
64
  } catch {
65
65
  // The shell remains denied even if server cleanup fails.
66
66
  }
@@ -18,7 +18,7 @@
18
18
  error = undefined;
19
19
 
20
20
  try {
21
- await runtime.client.logout();
21
+ await runtime.client.auth.logout();
22
22
  await runtime.hardNavigate(adminRoutePatterns.login);
23
23
  } catch (cause) {
24
24
  error = cause instanceof Error ? cause.message : runtime.i18n.t("account:signOutFailed");
@@ -25,7 +25,7 @@
25
25
  error = undefined;
26
26
 
27
27
  try {
28
- await runtime.client.requestVerification(authCollection.slug, email);
28
+ await runtime.client.auth.requestVerification({ collection: authCollection.slug, email });
29
29
  sent = true;
30
30
  } catch (cause) {
31
31
  error =
@@ -67,9 +67,12 @@
67
67
  pending = true;
68
68
 
69
69
  try {
70
- await runtime.client.resetPassword(authCollection.slug, token, password, {
71
- signal: activeRequest.signal,
72
- });
70
+ await runtime.client.auth.resetPassword(
71
+ { collection: authCollection.slug, token, password },
72
+ {
73
+ signal: activeRequest.signal,
74
+ }
75
+ );
73
76
  if (!current()) return;
74
77
  complete = true;
75
78
  password = "";
@@ -43,9 +43,12 @@
43
43
  error = undefined;
44
44
 
45
45
  try {
46
- await runtime.client.verifyEmail(authCollection.slug, token, {
47
- signal: activeRequest.signal,
48
- });
46
+ await runtime.client.auth.verifyEmail(
47
+ { collection: authCollection.slug, token },
48
+ {
49
+ signal: activeRequest.signal,
50
+ }
51
+ );
49
52
  if (!current()) return;
50
53
  complete = true;
51
54
  } catch (cause) {
@@ -11,7 +11,11 @@ import {
11
11
  saveFormDraft,
12
12
  takeFormDraft,
13
13
  } from "@admin/core/forms/form-draft-recovery";
14
- import { documentFormValues, initialFormValues } from "@admin/core/forms/form-schema";
14
+ import {
15
+ changedFormValues,
16
+ documentFormValues,
17
+ initialFormValues,
18
+ } from "@admin/core/forms/form-schema";
15
19
  import { invalidFieldLabels } from "@admin/core/forms/form-validation";
16
20
  import type { NotificationCenter } from "@admin/core/notifications/notification-center.svelte";
17
21
  import { samePreparedCreateValues } from "@admin/core/bootstrap/admin-bootstrap";
@@ -395,9 +399,10 @@ export class DocumentController {
395
399
  this.#forceUnlockRequest = request;
396
400
  this.unlockOperation = true;
397
401
  try {
398
- await runtime.client.forceUnlock(this.collection.slug, this.currentDocument.id, {
399
- signal: request.signal,
400
- });
402
+ await runtime.client.auth.forceUnlock(
403
+ { collection: this.collection.slug, id: this.currentDocument.id },
404
+ { signal: request.signal }
405
+ );
401
406
  if (request.signal.aborted) return;
402
407
  notifications.success({ title: runtime.i18n.t("documents:accountUnlocked") });
403
408
  } catch (cause) {
@@ -500,6 +505,10 @@ export class DocumentController {
500
505
  const wasCreating = this.creating;
501
506
  try {
502
507
  const saved = await this.form.submit(this.validationFields, this.creating, async (values) => {
508
+ if (!wasCreating)
509
+ values = changedFormValues(this.validationFields, values, this.form.original, (path) =>
510
+ this.form.isInherited(path)
511
+ );
503
512
  if (this.globalResource) {
504
513
  if (publishingChanges) {
505
514
  return this.options.runtime.client.publishGlobalChanges(collectionSlug, values, {
@@ -545,10 +554,10 @@ export class DocumentController {
545
554
  if (this.collection?.capabilities.auth) {
546
555
  if (password === undefined)
547
556
  throw new Error(this.options.runtime.i18n.t("documents:enterNewAccountPassword"));
548
- return this.options.runtime.client.createAuthUser(collectionSlug, values, password, {
549
- signal: request.signal,
550
- locale: this.contentLocale,
551
- });
557
+ return this.options.runtime.client.auth.createUser(
558
+ { collection: collectionSlug, data: values, password },
559
+ { signal: request.signal, locale: this.contentLocale }
560
+ );
552
561
  }
553
562
  if (!this.uploadCollection) {
554
563
  return this.options.runtime.client.create(collectionSlug, values, {
@@ -7,7 +7,11 @@ import { RiduError } from "@riducms/sdk";
7
7
 
8
8
  import type { AdminDocument } from "@admin/core/api/admin-client";
9
9
  import { FormController, FormValidationError } from "@admin/core/forms/form-controller.svelte";
10
- import { documentFormValues, initialFormValues } from "@admin/core/forms/form-schema";
10
+ import {
11
+ changedFormValues,
12
+ documentFormValues,
13
+ initialFormValues,
14
+ } from "@admin/core/forms/form-schema";
11
15
  import { invalidFieldLabels } from "@admin/core/forms/form-validation";
12
16
  import type { NotificationCenter } from "@admin/core/notifications/notification-center.svelte";
13
17
  import type { AdminRuntime } from "@admin/core/runtime/admin-runtime.svelte";
@@ -450,6 +454,9 @@ export class ReferenceBrowserWorkflow {
450
454
  const saved = await this.form.submit(this.validationFields, this.creating, async (values) => {
451
455
  if (this.options.readOnly) throw new Error("This field is read-only.");
452
456
  if (this.editorDocument !== undefined) {
457
+ values = changedFormValues(this.validationFields, values, this.form.original, (path) =>
458
+ this.form.isInherited(path)
459
+ );
453
460
  if (this.collection.capabilities.upload) {
454
461
  return this.options.runtime.client.updateUpload(
455
462
  this.collection.slug,
@@ -485,10 +492,8 @@ export class ReferenceBrowserWorkflow {
485
492
  );
486
493
  }
487
494
  if (this.collection.capabilities.auth) {
488
- return this.options.runtime.client.createAuthUser(
489
- this.collection.slug,
490
- values,
491
- this.newUserPassword,
495
+ return this.options.runtime.client.auth.createUser(
496
+ { collection: this.collection.slug, data: values, password: this.newUserPassword },
492
497
  { signal: request.signal, locale: this.options.locale }
493
498
  );
494
499
  }
@@ -1,4 +1,5 @@
1
1
  <script lang="ts">
2
+ import { flushSync } from "svelte";
2
3
  import {
3
4
  Combobox,
4
5
  ComboboxInput,
@@ -33,13 +34,16 @@
33
34
  const values = $derived(selectManyValues(form.get(field.path)));
34
35
  const options = $derived(field.select?.options ?? []);
35
36
  const filtered = $derived(
36
- options.filter((option) =>
37
- option.label
38
- .toLocaleLowerCase(runtime.i18n.language)
39
- .includes(query.toLocaleLowerCase(runtime.i18n.language))
37
+ options.filter(
38
+ (option) =>
39
+ (!hasMany || !values.includes(option.value)) &&
40
+ option.label
41
+ .toLocaleLowerCase(runtime.i18n.language)
42
+ .includes(query.toLocaleLowerCase(runtime.i18n.language))
40
43
  )
41
44
  );
42
45
  const selectedLabel = $derived(options.find((option) => option.value === value)?.label ?? "");
46
+ const inputValue = $derived(open || hasMany ? query : selectedLabel);
43
47
  const placeholder = $derived(
44
48
  field.admin.placeholder ?? runtime.i18n.t("fields:select", { label: field.admin.label })
45
49
  );
@@ -58,6 +62,7 @@
58
62
 
59
63
  function setValues(next: string[]) {
60
64
  if (!editingBlocked) form.set(field.path, [...next]);
65
+ query = "";
61
66
  }
62
67
 
63
68
  function setValue(next: string) {
@@ -80,7 +85,7 @@
80
85
  if (!next) query = "";
81
86
  }
82
87
  }
83
- inputValue={open || hasMany ? query : selectedLabel}
88
+ {inputValue}
84
89
  >
85
90
  <div
86
91
  bind:this={anchor}
@@ -114,17 +119,26 @@
114
119
  autocorrect="off"
115
120
  autocapitalize="none"
116
121
  spellcheck={false}
117
- {placeholder}
122
+ placeholder={hasMany && values.length > 0 ? "" : placeholder}
118
123
  {...controlARIA}
119
124
  aria-label={field.admin.label}
120
125
  aria-required={field.required}
121
126
  clearOnDeselect
122
127
  onpointerdown={() => (open = true)}
123
128
  oninput={(event) => {
124
- query = event.currentTarget.value;
125
- open = true;
129
+ const next = event.currentTarget.value;
130
+ // Filter before Bits chooses its keyboard highlight from the rendered options.
131
+ flushSync(() => {
132
+ query = next;
133
+ open = true;
134
+ });
126
135
  }}
127
- />
136
+ >
137
+ {#snippet child({ props })}
138
+ <!-- Bits writes the picked label internally; tags already display it. -->
139
+ <input {...props} value={inputValue} />
140
+ {/snippet}
141
+ </ComboboxInput>
128
142
  </div>
129
143
  {#if !hasMany && value}
130
144
  <button
@@ -158,9 +172,11 @@
158
172
  {option.label}
159
173
  </ComboboxItem>
160
174
  {:else}
161
- <p class="ridu-combobox-message">
162
- {runtime.i18n.t("fields:noMatch", { label: field.admin.label })}
163
- </p>
175
+ {#if query.trim()}
176
+ <p class="ridu-combobox-message">
177
+ {runtime.i18n.t("fields:noMatch", { label: field.admin.label })}
178
+ </p>
179
+ {/if}
164
180
  {/each}
165
181
  </ComboboxViewport>
166
182
  </ComboboxContent>