@nest-my-admin/core 0.2.0 → 0.3.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.
Files changed (80) hide show
  1. package/README.md +186 -5
  2. package/dist/admin.module.js +3 -1
  3. package/dist/admin.module.js.map +1 -1
  4. package/dist/api/admin-api.service.d.ts +20 -0
  5. package/dist/api/admin-api.service.js +141 -25
  6. package/dist/api/admin-api.service.js.map +1 -1
  7. package/dist/api/write-participant.d.ts +5 -0
  8. package/dist/audit/audit-participant.js +4 -2
  9. package/dist/audit/audit-participant.js.map +1 -1
  10. package/dist/audit/audit-reader.d.ts +2 -0
  11. package/dist/audit/audit-reader.js +9 -1
  12. package/dist/audit/audit-reader.js.map +1 -1
  13. package/dist/audit/entities.d.ts +5 -0
  14. package/dist/audit/entities.js.map +1 -1
  15. package/dist/contract.d.ts +76 -4
  16. package/dist/crud/validate-write.d.ts +2 -0
  17. package/dist/crud/validate-write.js +2 -0
  18. package/dist/crud/validate-write.js.map +1 -1
  19. package/dist/decorators/admin-group.d.ts +6 -1
  20. package/dist/decorators/admin-group.js +6 -1
  21. package/dist/decorators/admin-group.js.map +1 -1
  22. package/dist/decorators/admin-resource.d.ts +5 -0
  23. package/dist/decorators/admin-resource.js.map +1 -1
  24. package/dist/decorators/admin-settings-page.d.ts +9 -1
  25. package/dist/decorators/admin-settings-page.js.map +1 -1
  26. package/dist/files/admin-files.service.d.ts +76 -0
  27. package/dist/files/admin-files.service.js +113 -0
  28. package/dist/files/admin-files.service.js.map +1 -0
  29. package/dist/files/entities.d.ts +2 -1
  30. package/dist/files/entities.js +2 -1
  31. package/dist/files/entities.js.map +1 -1
  32. package/dist/files/files.service.d.ts +44 -1
  33. package/dist/files/files.service.js +131 -0
  34. package/dist/files/files.service.js.map +1 -1
  35. package/dist/files/storage.d.ts +13 -1
  36. package/dist/files/storage.js.map +1 -1
  37. package/dist/files/transaction-files.d.ts +25 -0
  38. package/dist/files/transaction-files.js +63 -0
  39. package/dist/files/transaction-files.js.map +1 -0
  40. package/dist/http/admin-http.server.js +1 -1
  41. package/dist/http/admin-http.server.js.map +1 -1
  42. package/dist/http/ui-assets.d.ts +18 -1
  43. package/dist/http/ui-assets.js +33 -2
  44. package/dist/http/ui-assets.js.map +1 -1
  45. package/dist/i18n/localized-text.d.ts +2 -0
  46. package/dist/i18n/localized-text.js +6 -0
  47. package/dist/i18n/localized-text.js.map +1 -1
  48. package/dist/index.d.ts +4 -1
  49. package/dist/index.js +1 -0
  50. package/dist/index.js.map +1 -1
  51. package/dist/inlines/inline-config.d.ts +36 -0
  52. package/dist/inlines/inline-config.js +130 -0
  53. package/dist/inlines/inline-config.js.map +1 -0
  54. package/dist/inlines/inline-reader.d.ts +38 -0
  55. package/dist/inlines/inline-reader.js +84 -0
  56. package/dist/inlines/inline-reader.js.map +1 -0
  57. package/dist/inlines/inline-writer.d.ts +102 -0
  58. package/dist/inlines/inline-writer.js +545 -0
  59. package/dist/inlines/inline-writer.js.map +1 -0
  60. package/dist/pages/page-file-owner.d.ts +6 -0
  61. package/dist/pages/page-file-owner.js +6 -0
  62. package/dist/pages/page-file-owner.js.map +1 -1
  63. package/dist/pages/page-registry.d.ts +2 -0
  64. package/dist/pages/page-registry.js +3 -1
  65. package/dist/pages/page-registry.js.map +1 -1
  66. package/dist/pages/page.service.d.ts +1 -0
  67. package/dist/pages/page.service.js +1 -1
  68. package/dist/pages/page.service.js.map +1 -1
  69. package/dist/policy/admin-policy.service.d.ts +12 -0
  70. package/dist/policy/admin-policy.service.js +44 -5
  71. package/dist/policy/admin-policy.service.js.map +1 -1
  72. package/dist/registry/resource-registry.d.ts +22 -1
  73. package/dist/registry/resource-registry.js +83 -17
  74. package/dist/registry/resource-registry.js.map +1 -1
  75. package/dist/schema/build-resource-schema.js +1 -1
  76. package/dist/schema/build-resource-schema.js.map +1 -1
  77. package/dist/schema/field-config.d.ts +52 -6
  78. package/dist/schema/field-config.js +89 -11
  79. package/dist/schema/field-config.js.map +1 -1
  80. package/package.json +2 -2
package/README.md CHANGED
@@ -3,7 +3,9 @@
3
3
  A Django-admin-class admin panel for **NestJS + TypeORM**: declarative resources, forms from your DTOs,
4
4
  admin writes that go through your own services, and a prebuilt shadcn UI served by Nest itself.
5
5
 
6
- > Status: pre-alpha, version 0.2 (lists and forms, sign-in, permissions, files and rich text, actions, settings pages, audit log). APIs will change.
6
+ > Status: pre-alpha, version 0.2.1 (lists and forms, sign-in, permissions, files and rich text, actions, settings pages, audit log). APIs will change.
7
+ >
8
+ > **Upgrading from 0.2.0:** nothing breaks; see [Upgrading to 0.2.1](#upgrading-to-021) for what is new and what you may notice.
7
9
  >
8
10
  > **Upgrading from 0.1:** boot can now fail for an app that booted before, and a few behaviours changed. See [Upgrading to 0.2](#upgrading-to-02).
9
11
 
@@ -11,6 +13,14 @@ Requires NestJS 11 or 12, TypeORM 0.3.20+ or 1.x, Node 20.19+ (or Bun), and Post
11
13
  The package is ESM; CommonJS apps load it through Node's `require(esm)`. A CommonJS app compiled with
12
14
  `"module": "nodenext"` (the `nest new` default since Nest 11) needs TypeScript 5.8+; with `"module": "commonjs"` any 5.x works.
13
15
 
16
+ ```bash
17
+ npm install @nest-my-admin/core # the admin (brings the prebuilt UI with it)
18
+ npm install @nest-my-admin/auth # optional: the built-in sign-in
19
+ npm install -D @nest-my-admin/testing # optional: test helpers
20
+ ```
21
+
22
+ `nest-my-admin` (no scope) is the same package as `@nest-my-admin/core` under a shorter name: it re-exports it, at the same version.
23
+
14
24
  ```ts
15
25
  @AdminResource(Product, { icon: 'package' })
16
26
  export class ProductAdmin extends AdminResourceBase<Product> {
@@ -42,6 +52,23 @@ in the default create/update/delete, and `AdminModule.forRoot({ autoRegister: tr
42
52
  without a resource. `ne` and `nin` exclude NULLs (SQL semantics), and datetime filter values must be ISO-8601
43
53
  with a time zone.
44
54
 
55
+ ### Sidebar groups and order
56
+
57
+ ```ts
58
+ @AdminGroup({ key: 'settings', label: { en: 'Settings', fa: 'تنظیمات' }, icon: 'settings', order: 900 })
59
+ @Module({ providers: [BrandingPage, SeoPage] })
60
+ export class SettingsModule {}
61
+
62
+ @AdminSettingsPage('branding', { group: 'settings', order: 10, form: BrandingDto }) // first
63
+ @AdminSettingsPage('seo', { group: 'settings', order: 20, form: SeoDto }) // second
64
+ @AdminResource(Product, { order: 10 }) // first among its group's resources
65
+ ```
66
+
67
+ - **A group is a Nest module.** Resources and settings pages go to the group of the module that provides them; `@AdminGroup` on the module sets its `key` (default: the module's name in kebab-case, without `Module`), `label`, `icon` and `order` (lower first, default 100; then by label).
68
+ - **`group: '<key>'`** on a resource or a page puts it into another group. That group is configured by the module whose `@AdminGroup` has that key: any module of the app, whether the group's members are its own or not, resources, pages or both, in any import order. Such a module may provide nothing at all. A key that no `@AdminGroup` names gets its key as label (`odds-and-ends` → "Odds and ends"), no icon and order 100.
69
+ - **One module per key.** When two modules configure the same key with a different label, icon or order, the first one Nest discovers (import order) wins and a warning is logged at boot. The same options twice are fine.
70
+ - **`order` inside a group.** `@AdminResource(Entity, { order })` and `@AdminSettingsPage(name, { order })`: lower comes first, then the label in the reader's language; the default is 100, as for groups. A group lists its resources first and its settings pages after them, so `order` sorts resources among resources and pages among pages. The sidebar, the group's page, the home page and the command palette all show this order. `order` must be a finite number, or boot fails.
71
+
45
72
  ### Customising lists
46
73
 
47
74
  Put row restrictions in `query(qb, ctx)`. It applies to every read path: the list, GET, PATCH, DELETE, restore and
@@ -175,7 +202,9 @@ AdminModule.forRoot({
175
202
 
176
203
  - **Languages.**
177
204
  - The language switch appears when there is more than one locale, and the choice is remembered in the browser.
178
- - The UI sends it as `Accept-Language`. Labels given per language answer in it, falling back to the default
205
+ - The page itself (`GET <path>/` and every deep link) is served in the default `locale`: `<html lang dir>` (`dir` is `rtl` for `fa`, `ar`, `he` and `ur`), your `title` in that language as `<title>`, and `Content-Language`. It is the same page for every request; the browser's `Accept-Language` is not consulted, because the UI starts in the default locale too.
206
+ - A reader who switched to another language gets the default-locale page for the moment before the script runs, which then sets `lang`, `dir` and the title for their choice (the choice lives in their browser, where the server cannot see it).
207
+ - The UI sends the language in use as `Accept-Language` with every API request. Labels given per language answer in it, falling back to the default
179
208
  locale.
180
209
  - Resources and hooks see it as `ctx.locale`.
181
210
  - Field labels, help, placeholders and enum value labels take the same `{ en, fa }` form (see Fields and forms).
@@ -297,6 +326,15 @@ export class ProductAdmin extends AdminResourceBase<Product> {
297
326
  - `s3Storage({ endpoint, region, bucket, accessKeyId, secretAccessKey, publicBaseUrl?, forcePathStyle? })` works with S3 and compatible stores (MinIO, R2), with no SDK.
298
327
  - Anything else: implement `AdminStorage` (`put`, `get`, `delete`, `publicUrl`).
299
328
  - Files are tracked in the `nma_file` table (`ADMIN_FILE_ENTITIES`; `files.dataSource` names another DataSource).
329
+ - **Storage keys.** The admin makes up a key at upload and never changes it:
330
+
331
+ | The file of | Upload query | `nma_file.resource` / `recordId` | Storage key |
332
+ | --- | --- | --- | --- |
333
+ | a resource's record | `resource=<resource>&field=<field>` | `<resource>` / the record's id | `<resource>/<field>/<yyyy>/<mm>/<uuid><ext>` |
334
+ | a settings page | `resource=page:<name>&field=<field>` | `page:<name>` / `page` | `page/<name>/<field>/<yyyy>/<mm>/<uuid><ext>` |
335
+
336
+ - `<yyyy>/<mm>` is the month of the upload in UTC, `<uuid>` a random UUID, and `<ext>` the extension of the type found in the bytes (`.png`, `.pdf`; nothing when the type has none).
337
+ - A page is written `page:<name>` wherever it is named as the owner, and `page/<name>/` in the key: a colon is not a file name everywhere. Example: `page/branding/logo/2026/10/ca56b927-6a9a-40c9-8967-28a6433f7b59.png`.
300
338
  - **Fields.**
301
339
  - The column holds the storage key (`product/image/2026/10/<uuid>.png`), or the list of keys for `many`. Records carry `_files[field]` with each file's `name`, `size`, `contentType` and `url`.
302
340
  - `file.accept` lists types (`image/*`, `application/pdf`); the image widget defaults to `image/*`. `file.maxBytes` overrides `files.maxBytes` (default 10 MiB); both stop at 2147483647 bytes (2 GiB - 1).
@@ -317,7 +355,7 @@ export class ProductAdmin extends AdminResourceBase<Product> {
317
355
  - The stored HTML holds the full URL of each image. Changing the admin `path` or the storage's `publicBaseUrl` later breaks the images of existing records (and the admin no longer recognises them as the record's files), so rewrite the stored HTML when you move either.
318
356
  - **Duplicate** copies the text without its images, for the same reason it copies no files.
319
357
  - The editor is a separate chunk of the UI, loaded only by forms with a rich-text field.
320
- - **API.** `POST /api/uploads?resource=&field=` takes the file itself as the request body (not multipart) with `Content-Type: application/octet-stream`, `Content-Length` and an optional percent-encoded `X-File-Name`, and answers `{ token, name, contentType, size, url? }`. Do not send the file's own type: a body parser of your app (Nest's JSON parser, for one) would read an `application/json` body before the admin does, and the upload is then refused with a 400 that says so. A write then sends `{ token }` for a new file, `{ key }` to keep a stored one, or `null`; a `many` field sends the whole list. Sending the same `{ token }` again for the same record and field (a retry after a lost answer) keeps the file.
358
+ - **API.** `POST /api/uploads?resource=&field=` (`resource=page:<name>` for a settings page) takes the file itself as the request body (not multipart) with `Content-Type: application/octet-stream`, `Content-Length` and an optional percent-encoded `X-File-Name`, and answers `{ token, name, contentType, size, url? }`. Do not send the file's own type: a body parser of your app (Nest's JSON parser, for one) would read an `application/json` body before the admin does, and the upload is then refused with a 400 that says so. A write then sends `{ token }` for a new file, `{ key }` to keep a stored one, or `null`; a `many` field sends the whole list. Sending the same `{ token }` again for the same record and field (a retry after a lost answer) keeps the file.
321
359
 
322
360
  **Security notes**
323
361
 
@@ -330,6 +368,74 @@ export class ProductAdmin extends AdminResourceBase<Product> {
330
368
  - Rich text is sanitized on the server at every write, whatever the browser sent. Links keep http(s), mailto and relative addresses and get `rel="noopener noreferrer nofollow"`; an `<img>` is kept only when it points at a file of this admin that belongs to the record (or that the same user just uploaded for this field). A custom `files.sanitizeHtml` is trusted to do the same.
331
369
  - An unsaved upload can be fetched only by the user who made it.
332
370
 
371
+ **Serving public files yourself**
372
+
373
+ A storefront needs the public files (product images, the shop's logo) without an admin session. With a `publicBaseUrl` the storage serves them. Without one, serve them from a route of your own and ask `AdminFiles` which keys are public; do not read `nma_file` yourself.
374
+
375
+ ```ts
376
+ import { AdminFiles } from '@nest-my-admin/core';
377
+ import { Controller, Get, NotFoundException, Param, Req, Res, StreamableFile } from '@nestjs/common';
378
+ import type { Request, Response } from 'express';
379
+
380
+ /** Images a browser may show in place; everything else is downloaded. */
381
+ const RASTER = new Set(['image/jpeg', 'image/png', 'image/webp', 'image/gif', 'image/avif']);
382
+
383
+ @Controller('media')
384
+ export class MediaController {
385
+ constructor(private readonly files: AdminFiles) {} // AdminModule is global: nothing to import
386
+
387
+ @Get('*key') // Nest 11 (Express 5): the segments of the key
388
+ async serve(@Param('key') key: string | string[], @Req() req: Request, @Res({ passthrough: true }) res: Response): Promise<StreamableFile | undefined> {
389
+ const path = Array.isArray(key) ? key.join('/') : key;
390
+ // Express answers HEAD with the GET route: the row is enough for that, the object is not opened.
391
+ const file = req.method === 'HEAD' ? await this.files.publicFile(path) : await this.files.openPublic(path);
392
+ if (!file) throw new NotFoundException();
393
+ res.setHeader('Content-Type', file.contentType.startsWith('text/') ? 'text/plain; charset=utf-8' : file.contentType);
394
+ res.setHeader('Content-Length', String(file.size));
395
+ res.setHeader('Cache-Control', 'public, max-age=31536000, immutable'); // a replaced file gets a new key
396
+ res.setHeader('X-Content-Type-Options', 'nosniff');
397
+ res.setHeader('Content-Security-Policy', "default-src 'none'; sandbox");
398
+ if (!RASTER.has(file.contentType)) {
399
+ const name = encodeURIComponent(file.name).replace(/['()*]/g, (char) => `%${char.charCodeAt(0).toString(16).toUpperCase()}`);
400
+ res.setHeader('Content-Disposition', `attachment; filename*=UTF-8''${name}`);
401
+ }
402
+ if (!('body' in file)) return undefined;
403
+ const { body } = file;
404
+ res.on('close', () => body.destroy()); // a reader who leaves early must not keep the file or the connection open
405
+ return new StreamableFile(body);
406
+ }
407
+ }
408
+ ```
409
+
410
+ - **Keep the headers.** Uploaded bytes are someone else's content served from your origin. `nosniff` and the type found at upload stop a browser from guessing another type; the `Content-Security-Policy` stops anything served here from running or loading anything if it is opened as a page; everything but a raster image is a download (`Content-Disposition: attachment`), and text is only ever `text/plain`. This is what the admin's own download route does.
411
+ - `publicFile(key)` answers `{ key, name, contentType, size }` or `null`, from the `nma_file` table alone (the storage is not touched): enough for a `HEAD`, or to decide before streaming.
412
+ - `openPublic(key)` makes the same check and then reads the storage: `{ body, contentType, size, name }`, or `null` when the key is not a public file or the object is gone from the storage. `size` is the stored object's size, or the size recorded at upload when the storage does not report one. **Consume `body` or destroy it**: an unread body holds a file descriptor or a connection to the storage.
413
+ - **Public** means a row with `visibility: 'public'` (the field's `file: { visibility: 'public' }`) and `status: 'attached'` (a saved record or settings page holds it). A private file, an upload nobody saved yet, a file its record let go of and a key the admin never issued are all `null`, and so is anything that is not a key (an empty string, `..` segments, a backslash, more than 500 characters): neither method throws for what a key is, so the key can come straight from the URL. No permission is checked.
414
+ - **Public is what was recorded at upload.** `AdminFiles` goes by the row's visibility, set from the field's `file.visibility` when the file was uploaded: a field you later make private keeps its earlier files public (new uploads are private). A record in the trash still holds its files, so its public files are still served; they stop when the record is purged or the file is removed from it.
415
+ - `contentType` is the type the admin found in the file's bytes at upload, never what the uploader claimed. The long `Cache-Control` is safe for the key (a replaced file gets a new one), but a file that is removed or made private stays in browser and CDN caches until it expires there; choose the lifetime with that in mind.
416
+ - `AdminFiles` is always injectable and follows `files.dataSource`. In an admin without `files`, both methods reject with an error that says `files` is missing (`files.enabled` tells beforehand); before the application has started they reject too.
417
+ - The admin's own `url` for a public file without a `publicBaseUrl` is `<path>/api/files/<key>`, which needs an admin session: build your storefront's link from the key (`/media/${key}`) instead.
418
+
419
+ #### Registering files yourself
420
+
421
+ A host that stores a file itself (an image fetched from another system, an attachment its own API took) registers it, so the admin's downloads, `_files`, `publicFile` and the delete/purge cleanup treat it like an upload:
422
+
423
+ ```ts
424
+ await dataSource.transaction(async (manager) => {
425
+ const file = await this.adminFiles.register(
426
+ { resource: 'product', field: 'image', recordId: product.id, body: imageStream, name: 'front.jpg' },
427
+ manager,
428
+ );
429
+ await manager.update(Product, product.id, { image: file.key }); // the record holds the key: you store it
430
+ });
431
+ ```
432
+
433
+ - The bytes are checked like an upload's: the type is read from the content and must fit the field's `accept`, and the size its `maxBytes`. A refusal is an `AdminValidationError` (422) on the field, and nothing is stored. The field must be a `file` or `image` field (not rich text); an unknown resource or field is an error.
434
+ - The object is stored under a new key in the usual format, and the row is `attached` to `recordId` (`RecordId`: a value, or `{ key: value }` for composite keys; for a settings page, `resource: 'page:<name>'` and any `recordId`). `visibility` defaults to the field's.
435
+ - **With `manager`** the row is written in that transaction when `nma_file` is on its DataSource (else at once), and if the transaction rolls back neither the row nor the object is left. Without one, the row is written at once.
436
+ - **It does not write the record.** Store `file.key` in the column yourself, in the same transaction.
437
+ - `release({ resource, field, recordId, key }, manager?)` is for a key the record no longer holds: its row goes, and its object after the transaction commits (at once without one). A key that is not that record's file in that field is left alone: `release` never deletes another record's file.
438
+
333
439
  ### Actions
334
440
 
335
441
  ```ts
@@ -405,7 +511,7 @@ export class ShopSettingsPage implements AdminSettingsSource<ShopSettingsDto> {
405
511
  }
406
512
  ```
407
513
 
408
- - **What it is.** One form that is not a table: a provider with `load` and `save`, shown in the sidebar under its module's group (or `group: '<key>'`) at `/p/<name>`. A group may hold only pages. List the class in a module's `providers`.
514
+ - **What it is.** One form that is not a table: a provider with `load` and `save`, shown in the sidebar under its module's group (or `group: '<key>'`) at `/p/<name>`. A group may hold only pages; its label, icon and order come from the module whose `@AdminGroup` has its key, and `order` places a page among the pages of its group (see [Sidebar groups and order](#sidebar-groups-and-order)). List the class in a module's `providers`.
409
515
  - **The form** comes from the DTO alone: one field per property with a class-validator decorator. `fields` and `@AdminField` set labels, help, widgets and `showIf`; `layout` works as on resources. `readonly`, `readonlyIf` and `restricted` do not exist on pages.
410
516
  - **`load(ctx)`** returns the stored values. Keys that are not form fields are dropped.
411
517
  - **`save(changes, ctx)`** gets only the keys that were sent: a save is partial and validated like a PATCH. The UI sends what changed; a group of fields (a nested DTO) is sent whole once one of its fields changed, so `save` can store it under its key.
@@ -416,13 +522,53 @@ export class ShopSettingsPage implements AdminSettingsSource<ShopSettingsDto> {
416
522
  - A save replaces it with any non-empty text, clears it with `null` (even when the DTO requires the property), and leaves it alone when the key is absent. An empty string is refused with 422: empty never means "clear".
417
523
  - `load` returns the secret like any other value (the admin needs to know whether one is set), and `save` gets the new text or `null`. Encrypt it in `save` if the table is not a safe place for it.
418
524
  - Never put a secret into an error message. As a last line of defence the admin replaces the exact values of secrets of 6 or more characters with `***` in errors leaving a save (messages, field errors, the log). That is best effort: shorter and transformed values are not found, and errors thrown by `load` outside a save are logged as thrown.
419
- - **Files.** File, image and rich-text fields work as on records, with `resource=page:<name>` for uploads. `save` gets an `AdminFile` (store its `key`) or `null`, and `load` returns the key, and `null` once the file was cleared: leaving the key out means "unknown", and the removed file is then kept. List fields (`many`) are not supported on pages.
525
+ - **Files.** File, image and rich-text fields work as on records, with `resource=page:<name>&field=<field>` for uploads; the stored key is `page/<name>/<field>/<yyyy>/<mm>/<uuid><ext>` (see Storage keys under [Files](#files-images-and-rich-text)). `save` gets an `AdminFile` (store its `key`) or `null`, and `load` returns the key, and `null` once the file was cleared: leaving the key out means "unknown", and the removed file is then kept. List fields (`many`) are not supported on pages.
420
526
  - **Permissions.** `page.<name>` views the page and `page.<name>.update` saves it; whoever may save may view.
421
527
  - `*` and `page.*` grant both codes of every page, and `page.*.update` saves every page. Resource wildcards (`*.view`, `*.update`, `product.*`) never reach a page.
422
528
  - Without view, the page is a 404 and absent from the sidebar. With view only, the form is read-only. Viewing as someone is always read-only.
423
529
  - **Names.** Letters, digits, `-` and `_`; not the name of a resource, an operation (`view`, `update`, …) or a resource's custom permission. Because page codes start with `page.`, **a resource cannot be named `page` in an admin with settings pages**: boot fails and suggests `@AdminResource(Page, { name: 'content-page' })`.
424
530
  - **API.** `GET /api/pages/:name` answers `{ schema, values, secrets, files }`, and `PATCH /api/pages/:name` takes the changed keys and answers the same.
425
531
 
532
+ ### Inlines
533
+
534
+ A parent's form can edit its child records (a product's variants, specifications and pictures; an order's lines), and one Save writes the parent and every change to its children in **one transaction**. The child is an ordinary resource, with its own permissions, scopes, fields, DTOs, hooks and service overrides; the parent's layout places it:
535
+
536
+ ```ts
537
+ @AdminResource(OrderLine, { name: 'order-line' })
538
+ export class OrderLineAdmin extends AdminResourceBase<OrderLine> {
539
+ form: FormConfig = { create: CreateOrderLineDto, update: UpdateOrderLineDto };
540
+ }
541
+
542
+ @AdminResource(Order)
543
+ export class OrderAdmin extends AdminResourceBase<Order> {
544
+ form: FormConfig = {
545
+ create: CreateOrderDto,
546
+ update: UpdateOrderDto,
547
+ layout: [
548
+ { section: { en: 'Order', fa: 'سفارش' }, fields: ['number', 'customer'], columns: 2 },
549
+ { section: { en: 'Lines', fa: 'ردیف‌ها' }, inline: OrderLineAdmin, display: 'table', fields: ['productId', 'description', 'quantity', 'unitPrice'], order: 'position', min: 1, max: 50 },
550
+ ],
551
+ };
552
+ }
553
+ ```
554
+
555
+ - **`inline`**: the child resource class. Its entity needs exactly one many-to-one (or owning one-to-one) relation to the parent's entity: that relation's field is the **link** (`orderId`). With several candidates, name it with `link: 'orderId'`. The server sets the link; a client that sends it is refused (422), and an update cannot change it.
556
+ - **`display`**: `cards` (default; each child is a card with the child's own form layout) or `table` (one row per child, one cell per field: for short rows like order lines; on phones the table scrolls sideways).
557
+ - **`fields`**: which of the child's form fields the inline shows, in order (default: the child's update form fields without the link). Fields it does not show keep their stored values, and their DTO defaults on create.
558
+ - **`order`**: a number field of the child. Rows are read sorted by it (then by key), and dragging or the up/down buttons write 0, 1, 2… into it on save. Without it rows go by key and cannot be reordered.
559
+ - **`min`**, **`max`** (default 100, at most 500): how many rows a save may leave, checked on the server after the changes (422 on the inline). `max` is also how many rows are read: a record with more shows them read-only, with a link to the child's list filtered by the parent.
560
+ - One level only: an inline cannot contain inlines (boot error). A child may be inlined into more than one parent (with different links).
561
+
562
+ **Permissions.** An inline never widens access: a user reads, adds, changes, removes or reorders a child through an inline only as they could through the child's own resource (`order-line.view`, `.create`, `.update`, `.delete`, its field levels and scopes, each row's `@AdminCan`), and only the children of this parent. A user without `<child>.view` does not see the inline at all; without `<child>.delete` there is no remove button, and a crafted delete is a 403 that saves nothing. Since children are saved with the parent, the save also needs `<parent>.create` (a new parent) or `<parent>.update`. View-as shows inlines read-only, like everything else.
563
+
564
+ **Saving.** The form sends, next to the parent's fields, `_inlines: { <child>: { create, update, delete, order } }` (`InlineChanges` in the contract): new rows with a `_key`, changed fields of changed rows with their `_id` (and `_version` for a child with a version column), the ids to delete, and the new order. Inside the parent's write the parent is saved first (a save with only inline changes does not update the parent), then each inline in layout order: deletes, updates, creates, each through the child resource's own `delete`/`update`/`create` and its write participants (files, audit). Any error rolls back everything; errors name the row (`_inlines.order-line.create.2.quantity`, `_inlines.order-line.update.17.unitPrice`) and the form shows them on that row. At most 500 entries per inline per request.
565
+
566
+ **Options that depend on the parent.** A child's `relationOptions(field, qb, ctx, values)` receives `values._parent`: the parent form's values as the user sees them (unsaved, in the picker; body over stored, on save), with `_parent._resource` naming the parent. Use it to narrow a picker to what the parent allows, such as the attributes of the product's category.
567
+
568
+ **History.** Each child change is the child's own audit entry (`order-line.created`, …) in the child's history, with `meta.parent` naming the parent (for readers who may view the parent's resource). The parent's History tab shows the parent's own entries only.
569
+
570
+ **Duplicate** copies the parent only, not its children (the copied form says so).
571
+
426
572
  ## Transactions, context and errors
427
573
 
428
574
  Every create, update and delete runs in one database transaction. Pass `ctx.manager` to your services so their writes
@@ -671,6 +817,41 @@ export class EmployeeAdmin extends AdminResourceBase<Employee> {
671
817
  - **Read replicas.** Reads outside a transaction (that check after commit; with `transactions: false` the before and after images too) go through `dataSource.manager`, which TypeORM replication may send to a replica. A lagging replica then still shows a deleted row (no stamp), or shows an update as unchanged (a smaller diff, or no entry).
672
818
  - **Scopes that depend on a field.** A record's history is open to whoever may open the record now. A reader whose scope depends on a field (`ownerId`, a status) reads the values the record had before it entered their scope, in the fields they may view.
673
819
 
820
+ ## Upgrading to 0.3.0
821
+
822
+ Nothing in 0.3.0 breaks an app that runs on 0.2.1: no option or export was removed or changed, no table or stored key changed, and a resource without inlines answers exactly as before (responses only gain fields). All five packages are 0.3.0; upgrade them together, as before.
823
+
824
+ **New**
825
+
826
+ - **Inlines**: child resources edited in the parent's form and saved with it in one transaction. See [Inlines](#inlines).
827
+ - **`AdminFiles.register()` and `release()`**: files a host stored itself become files of a record's field. See "Registering files yourself" under [Files](#files-images-and-rich-text).
828
+ - **A key column named `id` without a label is "شناسه" in Persian** (still "Id" in English).
829
+
830
+ **What a 0.2.1 app may notice**
831
+
832
+ - `relationOptions(field, qb, ctx, values)`: a key named `_parent` in `values` now has a meaning when the picker belongs to an inline (`?parent=` on the options route); without `?parent=` it is passed through as before.
833
+ - The UI's lists, sidebar and header: the sidebar and the header stay in place while the page scrolls, and in right-to-left languages table headers start on the right like their cells.
834
+
835
+ ## Upgrading to 0.2.1
836
+
837
+ Nothing in 0.2.1 breaks an app that runs on 0.2.0: no option or export was removed or changed, no table or stored key changed, and permissions decide as before. All five packages (`@nest-my-admin/core`, `ui`, `auth`, `testing` and `nest-my-admin`) are 0.2.1. Upgrade them together: `@nest-my-admin/auth` and `@nest-my-admin/testing` peer on the exact core version (0.2.1 of either does not install next to core 0.2.0), and core brings the UI of its own version.
838
+
839
+ **New**
840
+
841
+ - **`@AdminGroup` by key, for any members.** A module's `@AdminGroup` now configures the group of its key whoever the members are, also when they are only settings pages that say `group: '<key>'`, and also when the module provides nothing. See [Sidebar groups and order](#sidebar-groups-and-order).
842
+ - **`order`** on `@AdminResource` and `@AdminSettingsPage`: the place inside the group (lower first, default 100, then by label).
843
+ - **`AdminFiles`** (`publicFile(key)`, `openPublic(key)`): which keys are public files, for a host that serves them itself. See "Serving public files yourself" under [Files](#files-images-and-rich-text).
844
+ - **The page is in the admin's language**: `GET <path>/` answers `<html lang dir>`, `<title>` and `Content-Language` for the default `locale` and your `title`.
845
+ - **Documented:** the storage key of a settings page's file, `page/<name>/<field>/<yyyy>/<mm>/<uuid><ext>` (unchanged; it was only in the source).
846
+
847
+ **What a 0.2.0 app may notice**
848
+
849
+ - **A sidebar group can change its label, icon and place.** In 0.2.0 an `@AdminGroup` was read only when its module provided a resource, or a page without `group`. A group reached only through `group: '<key>'` therefore showed the key as its label ("Settings"), with no icon and order 100, even though a module carried `@AdminGroup({ key: '<key>', … })` (or was named like the key and carried `@AdminGroup` without a `key`). In 0.2.1 that module's label, icon and order apply. Group keys, URLs (`/g/<key>`) and permissions are unchanged. If you worked around it by leaving `group` off some pages, both spellings now give the same group.
850
+ - A module that carries `@AdminGroup` now also wins over a module without the decorator whose name gives the same key, whichever is imported first (in 0.2.0, the one whose resource was registered first).
851
+ - Two modules that configure one key differently: the first in import order wins (in 0.2.0, the first that had a resource or a page registered, which is nearly always the same), and 0.2.1 logs a warning at boot that names both.
852
+ - **The served HTML.** `GET <path>/` used to answer `<html lang="en" dir="ltr">` and `<title>Admin</title>` whatever the admin's language. It now answers with the default `locale` (`lang`, `dir`, `Content-Language`) and your `title` in it. The page is still one page for every request (it does not vary by `Accept-Language`, and there is no `Vary` header), and `Cache-Control` is still `no-cache`. An admin with `locale: 'en'` and no `title` gets byte-for-byte what it got before, plus `Content-Language: en`. The UI behaves as before once it runs. If you serve your own UI build (`uiDistPath`), the `lang` and `dir` of its `<html>` and the `<title>` of its `<head>` are replaced the same way.
853
+ - **Boot fails for `order` that is not a finite number** on a resource or a settings page. TypeScript did not accept the option in 0.2.0, so only untyped code that already passed one can be hit.
854
+
674
855
  ## Upgrading to 0.2
675
856
 
676
857
  **Boot can now fail.** Each of these stops the app at start, with a message that names the class:
@@ -13,6 +13,7 @@ import { AuditReader } from './audit/audit-reader.js';
13
13
  import { AdminAudit } from './audit/audit.service.js';
14
14
  import { AdminAuthService } from './auth/auth.service.js';
15
15
  import { AdminPolicy } from './policy/admin-policy.service.js';
16
+ import { AdminFiles } from './files/admin-files.service.js';
16
17
  import { FilesService } from './files/files.service.js';
17
18
  import { AdminRbac } from './rbac/rbac.service.js';
18
19
  import { AdminHttpServer } from './http/admin-http.server.js';
@@ -33,12 +34,13 @@ let AdminModule = AdminModule_1 = class AdminModule {
33
34
  AdminAudit,
34
35
  AdminRbac,
35
36
  FilesService,
37
+ AdminFiles,
36
38
  AdminPolicy,
37
39
  AdminApiService,
38
40
  AuditReader,
39
41
  AdminHttpServer,
40
42
  ],
41
- exports: [ResourceRegistry],
43
+ exports: [ResourceRegistry, AdminFiles],
42
44
  };
43
45
  }
44
46
  };
@@ -1 +1 @@
1
- {"version":3,"file":"admin.module.js","sourceRoot":"","sources":["../src/admin.module.ts"],"names":[],"mappings":";;;;;;;AAAA,OAAO,EAAE,MAAM,EAAsB,MAAM,gBAAgB,CAAC;AAC5D,OAAO,EAAE,eAAe,EAAE,MAAM,cAAc,CAAC;AAC/C,OAAO,EAAE,eAAe,EAAE,MAAM,4BAA4B,CAAC;AAC7D,OAAO,EAAE,aAAa,EAAE,MAAM,gBAAgB,CAAC;AAC/C,OAAO,EAAE,WAAW,EAAE,MAAM,yBAAyB,CAAC;AACtD,OAAO,EAAE,UAAU,EAAE,MAAM,0BAA0B,CAAC;AACtD,OAAO,EAAE,gBAAgB,EAAE,MAAM,wBAAwB,CAAC;AAC1D,OAAO,EAAE,WAAW,EAAE,MAAM,kCAAkC,CAAC;AAC/D,OAAO,EAAE,YAAY,EAAE,MAAM,0BAA0B,CAAC;AACxD,OAAO,EAAE,SAAS,EAAE,MAAM,wBAAwB,CAAC;AACnD,OAAO,EAAE,eAAe,EAAE,MAAM,6BAA6B,CAAC;AAC9D,OAAO,EAAE,mBAAmB,EAA2B,MAAM,cAAc,CAAC;AAC5E,OAAO,EAAE,YAAY,EAAE,MAAM,0BAA0B,CAAC;AACxD,OAAO,EAAE,gBAAgB,EAAE,MAAM,iCAAiC,CAAC;AAG5D,IAAM,WAAW,mBAAjB,MAAM,WAAW;IACf,AAAP,MAAM,CAAC,OAAO,CAAC,OAAO,GAAuB,EAAE;QAC7C,OAAO;YACL,MAAM,eAAa;YACnB,MAAM,EAAE,IAAI;YACZ,OAAO,EAAE,CAAC,eAAe,CAAC;YAC1B,SAAS,EAAE;gBACT,EAAE,OAAO,EAAE,aAAa,EAAE,QAAQ,EAAE,mBAAmB,CAAC,OAAO,CAAC,EAAE;gBAClE,gBAAgB;gBAChB,YAAY;gBACZ,gBAAgB;gBAChB,UAAU;gBACV,SAAS;gBACT,YAAY;gBACZ,WAAW;gBACX,eAAe;gBACf,WAAW;gBACX,eAAe;aAChB;YACD,OAAO,EAAE,CAAC,gBAAgB,CAAC;SAC5B,CAAC;IACJ,CAAC;CACF,CAAA;AAtBY,WAAW;IADvB,MAAM,CAAC,EAAE,CAAC;GACE,WAAW,CAsBvB"}
1
+ {"version":3,"file":"admin.module.js","sourceRoot":"","sources":["../src/admin.module.ts"],"names":[],"mappings":";;;;;;;AAAA,OAAO,EAAE,MAAM,EAAsB,MAAM,gBAAgB,CAAC;AAC5D,OAAO,EAAE,eAAe,EAAE,MAAM,cAAc,CAAC;AAC/C,OAAO,EAAE,eAAe,EAAE,MAAM,4BAA4B,CAAC;AAC7D,OAAO,EAAE,aAAa,EAAE,MAAM,gBAAgB,CAAC;AAC/C,OAAO,EAAE,WAAW,EAAE,MAAM,yBAAyB,CAAC;AACtD,OAAO,EAAE,UAAU,EAAE,MAAM,0BAA0B,CAAC;AACtD,OAAO,EAAE,gBAAgB,EAAE,MAAM,wBAAwB,CAAC;AAC1D,OAAO,EAAE,WAAW,EAAE,MAAM,kCAAkC,CAAC;AAC/D,OAAO,EAAE,UAAU,EAAE,MAAM,gCAAgC,CAAC;AAC5D,OAAO,EAAE,YAAY,EAAE,MAAM,0BAA0B,CAAC;AACxD,OAAO,EAAE,SAAS,EAAE,MAAM,wBAAwB,CAAC;AACnD,OAAO,EAAE,eAAe,EAAE,MAAM,6BAA6B,CAAC;AAC9D,OAAO,EAAE,mBAAmB,EAA2B,MAAM,cAAc,CAAC;AAC5E,OAAO,EAAE,YAAY,EAAE,MAAM,0BAA0B,CAAC;AACxD,OAAO,EAAE,gBAAgB,EAAE,MAAM,iCAAiC,CAAC;AAG5D,IAAM,WAAW,mBAAjB,MAAM,WAAW;IACf,AAAP,MAAM,CAAC,OAAO,CAAC,OAAO,GAAuB,EAAE;QAC7C,OAAO;YACL,MAAM,eAAa;YACnB,MAAM,EAAE,IAAI;YACZ,OAAO,EAAE,CAAC,eAAe,CAAC;YAC1B,SAAS,EAAE;gBACT,EAAE,OAAO,EAAE,aAAa,EAAE,QAAQ,EAAE,mBAAmB,CAAC,OAAO,CAAC,EAAE;gBAClE,gBAAgB;gBAChB,YAAY;gBACZ,gBAAgB;gBAChB,UAAU;gBACV,SAAS;gBACT,YAAY;gBACZ,UAAU;gBACV,WAAW;gBACX,eAAe;gBACf,WAAW;gBACX,eAAe;aAChB;YACD,OAAO,EAAE,CAAC,gBAAgB,EAAE,UAAU,CAAC;SACxC,CAAC;IACJ,CAAC;CACF,CAAA;AAvBY,WAAW;IADvB,MAAM,CAAC,EAAE,CAAC;GACE,WAAW,CAuBvB"}
@@ -15,6 +15,10 @@ export declare class AdminApiService {
15
15
  /** The write transaction and what takes part in every change made inside it: files, then the audit log. */
16
16
  private readonly writes;
17
17
  constructor(registry: ResourceRegistry, options: ResolvedAdminOptions, policy: AdminPolicy, files: FilesService, pages: PageRegistry, audit: AdminAudit);
18
+ /** `_inlines` of a POST or PATCH: the children's changes, saved inside the parent's write. */
19
+ private readonly inlineWriter;
20
+ /** `_inlines` of a record: its children through each child resource's read path. */
21
+ private readonly inlines;
18
22
  /** Runs `@AdminAction`s; it writes and serializes through this service. */
19
23
  private readonly actions;
20
24
  /** Settings pages; they save through this service's write transaction. */
@@ -37,12 +41,21 @@ export declare class AdminApiService {
37
41
  private records;
38
42
  /** One record with every relation field loaded (detail and write responses). */
39
43
  private record;
44
+ /** Records as the detail endpoint answers them, every relation field loaded (in one lookup per field, not per record). */
45
+ private fullRecords;
40
46
  /** One transaction on the target's DataSource with `ctx.manager` set (see `WriteRunner.run`). */
41
47
  private write;
42
48
  /** One change inside a write, with every participant around the host `method` (see `WriteRunner.change`). */
43
49
  private change;
44
50
  create(name: string, body: unknown, ctx: AdminContext): Promise<AdminRecord>;
45
51
  update(name: string, rawId: string, body: unknown, ctx: AdminContext, version?: number): Promise<AdminRecord>;
52
+ /**
53
+ * The parent's own validation and the preparation of its `_inlines` (`InlineWriter.prepare`), with the 422s of both
54
+ * in one answer. A resource without inlines validates as before. On update, only a body with `_inlines` gets a plan.
55
+ */
56
+ private withInlines;
57
+ /** The answer of a write: the record, and for a parent with inlines its `_inlines` as they are after the save. */
58
+ private withInlineRows;
46
59
  remove(name: string, rawId: string, ctx: AdminContext, version?: number): Promise<void>;
47
60
  /**
48
61
  * Optimistic concurrency (`If-Match`): inside the write transaction, reads the row, locked on drivers that lock rows
@@ -65,6 +78,13 @@ export declare class AdminApiService {
65
78
  * `list.search` fields, else its title column) or `?ids=` (up to 100, to show titles of known ids).
66
79
  */
67
80
  fieldOptions(name: string, fieldName: string, query: URLSearchParams, ctx: AdminContext): Promise<OptionsResponse>;
81
+ /**
82
+ * A child's picker inside a parent's form: `?parent=<parent resource>` and `values._parent` (the parent form's values)
83
+ * come together. `parent` must inline this resource and be one the user may view; `_parent` reaches relationOptions()
84
+ * with `_resource` set to it (and keys starting with `_` dropped), as on save. Like the rest of `values`, it can only
85
+ * narrow the options: the target's query() and scopes are applied before.
86
+ */
87
+ private parentValues;
68
88
  /**
69
89
  * Deletes each record on its own (its own transaction, hooks, `query()` and trash), so one failure never undoes the
70
90
  * others. Answers `{ ok, failed }` in request order; failures carry the code and message the single DELETE would.