portal-mojo 0.2.17 → 0.2.18

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.
@@ -53,7 +53,12 @@ with raw `mojoList` — `SettingModel.normalizeListParams` strips `key`, so the
53
53
  model's list hook cannot do this. More than one global row for a key disables
54
54
  Save and Reset with a notice naming the ids: `Setting.Meta.ordering = ["key"]`
55
55
  leaves the server's `.first()` undefined among duplicates, so editing one could
56
- change a row the renderer never reads.
56
+ change a row the renderer never reads. Save and Reset also stay disabled until
57
+ the lookup succeeds (pending or failed reads say so beside the buttons), and a
58
+ save with no known row (`saveCategoryValue(category, null, …)`) re-reads first:
59
+ it refuses without writing when that read fails or finds a row, and creates
60
+ only when the list is empty. `resetCategory(category, null)` resolves `null`,
61
+ meaning nothing was reset.
57
62
 
58
63
  **Write:** the whole category as one object — `POST /api/settings` to create
59
64
  (`{key, is_secret: false, group: null, value}`) or `POST /api/settings/<id>`
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "portal-mojo",
3
- "version": "0.2.17",
3
+ "version": "0.2.18",
4
4
  "description": "React portal toolkit for django-mojo — typed client + params store, mission-control UI, dependency-free SVG charts, admin section bundles.",
5
5
  "type": "module",
6
6
  "license": "Apache-2.0",
@@ -94,9 +94,10 @@ function RoleField({ category, draft, role, kind, name, error, disabled, onChang
94
94
  function CategoryPanel({ category, categoryKey }: { category: RenditionCategory; categoryKey: string }) {
95
95
  const queryClient = useQueryClient();
96
96
  const rowsQuery = useQuery({ queryKey: rowQueryKey(category.key), queryFn: () => findCategoryRows(category.key), retry: false, refetchOnWindowFocus: false });
97
- const rows = rowsQuery.data ?? [];
98
- const row: SettingRow | null = rows[0] ?? null;
99
- const duplicates = rows.length > 1;
97
+ // Rows only from a successful read: a failed or pending read is "unknown", never "no row".
98
+ const rows = rowsQuery.isSuccess ? rowsQuery.data : null;
99
+ const row: SettingRow | null = rows?.[0] ?? null;
100
+ const duplicates = (rows?.length ?? 0) > 1;
100
101
  const [draft, setDraft] = useState<CategoryDraft>(() => valueToDraft(category, category.override));
101
102
  const [errors, setErrors] = useState<Record<string, string>>({});
102
103
  const [formError, setFormError] = useState('');
@@ -134,6 +135,7 @@ function CategoryPanel({ category, categoryKey }: { category: RenditionCategory;
134
135
  setErrors((current) => ({ ...current, [key]: routed.message }));
135
136
  };
136
137
  const save = async () => {
138
+ if (!rowsQuery.isSuccess || duplicates) return;
137
139
  setBusy(true); setFormError(''); setErrors({});
138
140
  try {
139
141
  await saveCategoryValue(category, row, value);
@@ -146,6 +148,7 @@ function CategoryPanel({ category, categoryKey }: { category: RenditionCategory;
146
148
  }
147
149
  };
148
150
  const reset = async () => {
151
+ if (!rowsQuery.isSuccess || duplicates) return;
149
152
  const ok = await modal.confirm({
150
153
  title: `Reset ${renditionCategoryLabel(categoryKey).toLowerCase()} rendering to defaults?`,
151
154
  message: 'Every role goes back to the framework defaults and the default set of roles runs on upload. Existing files are not touched.',
@@ -155,8 +158,9 @@ function CategoryPanel({ category, categoryKey }: { category: RenditionCategory;
155
158
  if (!ok) return;
156
159
  setBusy(true); setFormError(''); setErrors({});
157
160
  try {
158
- await resetCategory(category, row);
159
- toast.success(`${renditionCategoryLabel(categoryKey)} rendering reset to defaults`);
161
+ const resetRow = await resetCategory(category, row);
162
+ if (resetRow) toast.success(`${renditionCategoryLabel(categoryKey)} rendering reset to defaults`);
163
+ else toast.info('There was no stored override to reset.');
160
164
  await refresh();
161
165
  } catch (reason) {
162
166
  fail(reason);
@@ -165,14 +169,17 @@ function CategoryPanel({ category, categoryKey }: { category: RenditionCategory;
165
169
  }
166
170
  };
167
171
 
168
- const disabled = busy || duplicates || rowsQuery.isPending;
172
+ const disabled = busy || duplicates || !rowsQuery.isSuccess;
173
+ const status = rowsQuery.isPending ? 'Reading the current override…'
174
+ : rowsQuery.isError ? 'The current override could not be read — Save is disabled until it loads.'
175
+ : row ? `Override row #${row.id}` : 'No override row yet — Save creates one.';
169
176
  return <div className="media-rendering-panel">
170
177
  {hasH265 && <p className="media-rendering-note">
171
178
  <b>H.265 (HEVC)</b> encodes take several times longer than H.264 and do not play in every browser. Keep <code>video_mp4</code> as the H.264 compatibility rendition and enable <code>video_hevc</code> on upload only when the extra encode time is worth it.
172
179
  </p>}
173
180
  {rowsQuery.error && <div className="form-alert" role="alert">The current override could not be read: {rowsQuery.error.message} <button className="btn btn-compact" onClick={() => void rowsQuery.refetch()}>Retry</button></div>}
174
181
  {duplicates && <div className="form-alert" role="alert">
175
- More than one global row exists for <code>{category.key}</code> (ids {rows.map((entry) => entry.id).join(', ')}). The server reads an undefined one, so this page will not edit them; remove the duplicates on the server first.
182
+ More than one global row exists for <code>{category.key}</code> (ids {rows?.map((entry) => entry.id).join(', ')}). The server reads an undefined one, so this page will not edit them; remove the duplicates on the server first.
176
183
  </div>}
177
184
  {formError && <div className="form-alert" role="alert">{formError}</div>}
178
185
  <div className="media-rendering-roles">
@@ -210,7 +217,7 @@ function CategoryPanel({ category, categoryKey }: { category: RenditionCategory;
210
217
  <div className="media-rendering-actions">
211
218
  <button className="btn btn-primary" disabled={disabled || !dirty} onClick={() => void save()}>{busy ? 'Saving…' : 'Save'}</button>
212
219
  <button className="btn" disabled={disabled || !row || category.override == null} onClick={() => void reset()}>Reset to defaults…</button>
213
- <span className="dim">{row ? `Override row #${row.id}` : 'No override row yet — Save creates one.'}</span>
220
+ <span className="dim">{status}</span>
214
221
  </div>
215
222
  </div>;
216
223
  }
@@ -6,7 +6,7 @@
6
6
  // Everything about roles, fields, choices and limits comes from the
7
7
  // descriptor. The ONE rule this module hardcodes is the mp4-only trio
8
8
  // (`codec`/`crf`/`preset`), which the server refuses on a non-mp4 role.
9
- import { mojoCall, mojoList, mojoSave, withFreshAuth } from '../../client/runtime';
9
+ import { isReauthRequired, mojoCall, mojoList, mojoSave, withFreshAuth } from '../../client/runtime';
10
10
  import type { SettingRow } from '../settings/model';
11
11
 
12
12
  export type RenditionCategoryKey = 'image' | 'video' | 'document';
@@ -251,13 +251,36 @@ export function routeRenditionError(message: string, knownRoles: readonly string
251
251
  return { role: role!, field: field ?? null, message: rest! };
252
252
  }
253
253
 
254
- /** Create or update the category's global row. Imperative; a failure rejects. */
254
+ /**
255
+ * Create or update the category's global row. Imperative; a failure rejects.
256
+ * With `row === null` the handler proves the row is absent before creating:
257
+ * it re-reads the global rows and refuses (no write) when that read fails or
258
+ * finds a row, so no caller creates one blind. The read and the create are
259
+ * separate requests: two admins saving at the same moment can still both
260
+ * create (#8200 Challenge row 2); server-side uniqueness is #8201.
261
+ */
255
262
  export async function saveCategoryValue(category: RenditionCategory, row: SettingRow | null, value: Record<string, unknown>): Promise<SettingRow> {
256
263
  const changes: Record<string, unknown> = row ? { value } : { key: category.key, is_secret: false, group: null, value };
257
- return withFreshAuth(() => mojoSave<SettingRow>('/api/settings', row?.id ?? null, changes));
264
+ return withFreshAuth(async () => {
265
+ if (!row) {
266
+ let existing: SettingRow[];
267
+ try {
268
+ existing = await findCategoryRows(category.key);
269
+ } catch (error) {
270
+ // A step-up refusal goes back to withFreshAuth, which re-runs the read after re-login.
271
+ if (isReauthRequired(error)) throw error;
272
+ throw new Error('The current override could not be read, so nothing was saved. Retry the read first.');
273
+ }
274
+ if (existing.length) throw new Error(`A global row for ${category.key} already exists (id ${existing.map((entry) => entry.id).join(', ')}); reload before saving.`);
275
+ }
276
+ return mojoSave<SettingRow>('/api/settings', row?.id ?? null, changes);
277
+ });
258
278
  }
259
279
 
260
- /** Reset = write `{}`. The live Setting model refuses DELETE, and `{}` is reported as `override: null`. */
280
+ /**
281
+ * Reset = write `{}`. The live Setting model refuses DELETE, and `{}` is reported as `override: null`.
282
+ * Resolves `null` when `row` is null: nothing was reset and nothing was sent.
283
+ */
261
284
  export async function resetCategory(category: RenditionCategory, row: SettingRow | null): Promise<SettingRow | null> {
262
285
  if (!row) return null;
263
286
  return saveCategoryValue(category, row, {});