diff --git a/packages/active-record/src/-private/builders/find-record.ts b/packages/active-record/src/-private/builders/find-record.ts index fca2dccef70..e0d3330907f 100644 --- a/packages/active-record/src/-private/builders/find-record.ts +++ b/packages/active-record/src/-private/builders/find-record.ts @@ -20,7 +20,7 @@ type FindRecordOptions = ConstrainedRequestOptions & { /** * Builds request options to fetch a single resource by a known id or identifier - * configured for the url and header expectations of most JSON:API APIs. + * configured for the url and header expectations of most ActiveRecord APIs. * * **Basic Usage** * diff --git a/packages/active-record/src/-private/builders/save-record.ts b/packages/active-record/src/-private/builders/save-record.ts index b1a3ec7279c..ccd99c7c668 100644 --- a/packages/active-record/src/-private/builders/save-record.ts +++ b/packages/active-record/src/-private/builders/save-record.ts @@ -24,6 +24,58 @@ function isExisting(identifier: StableRecordIdentifier): identifier is StableExi return 'id' in identifier && identifier.id !== null && 'type' in identifier && identifier.type !== null; } +/** + * Builds request options to delete record for resources, + * configured for the url, method and header expectations of ActiveRecord APIs. + * + * **Basic Usage** + * + * ```ts + * import { deleteRecord } from '@ember-data/active-record/request'; + * + * const person = this.store.peekRecord('person', '1'); + * + * // mark record as deleted + * store.deleteRecord(person); + * + * // persist deletion + * const data = await store.request(deleteRecord(person)); + * ``` + * + * **Supplying Options to Modify the Request Behavior** + * + * The following options are supported: + * + * - `host` - The host to use for the request, defaults to the `host` configured with `setBuildURLConfig`. + * - `namespace` - The namespace to use for the request, defaults to the `namespace` configured with `setBuildURLConfig`. + * - `resourcePath` - The resource path to use for the request, defaults to pluralizing the supplied type + * - `reload` - Whether to forcibly reload the request if it is already in the store, not supplying this + * option will delegate to the store's lifetimes service, defaulting to `false` if none is configured. + * - `backgroundReload` - Whether to reload the request if it is already in the store, but to also resolve the + * promise with the cached value, not supplying this option will delegate to the store's lifetimes service, + * defaulting to `false` if none is configured. + * - `urlParamsSetting` - an object containing options for how to serialize the query params (see `buildQueryParams`) + * + * ```ts + * import { deleteRecord } from '@ember-data/active-record/request'; + * + * const person = this.store.peekRecord('person', '1'); + * + * // mark record as deleted + * store.deleteRecord(person); + * + * // persist deletion + * const options = deleteRecord(person, { namespace: 'api/v1' }); + * const data = await store.request(options); + * ``` + * + * @method deleteRecord + * @public + * @static + * @for @ember-data/active-record/request + * @param record + * @param options + */ export function deleteRecord(record: unknown, options: ConstrainedRequestOptions = {}): DeleteRequestOptions { const identifier = recordIdentifierFor(record); assert(`Expected to be given a record instance`, identifier); @@ -52,10 +104,51 @@ export function deleteRecord(record: unknown, options: ConstrainedRequestOptions }; } +/** + * Builds request options to create new record for resources, + * configured for the url, method and header expectations of most ActiveRecord APIs. + * + * **Basic Usage** + * + * ```ts + * import { createRecord } from '@ember-data/active-record/request'; + * + * const person = this.store.createRecord('person', { name: 'Ted' }); + * const data = await store.request(createRecord(person)); + * ``` + * + * **Supplying Options to Modify the Request Behavior** + * + * The following options are supported: + * + * - `host` - The host to use for the request, defaults to the `host` configured with `setBuildURLConfig`. + * - `namespace` - The namespace to use for the request, defaults to the `namespace` configured with `setBuildURLConfig`. + * - `resourcePath` - The resource path to use for the request, defaults to pluralizing the supplied type + * - `reload` - Whether to forcibly reload the request if it is already in the store, not supplying this + * option will delegate to the store's lifetimes service, defaulting to `false` if none is configured. + * - `backgroundReload` - Whether to reload the request if it is already in the store, but to also resolve the + * promise with the cached value, not supplying this option will delegate to the store's lifetimes service, + * defaulting to `false` if none is configured. + * - `urlParamsSetting` - an object containing options for how to serialize the query params (see `buildQueryParams`) + * + * ```ts + * import { createRecord } from '@ember-data/active-record/request'; + * + * const person = this.store.createRecord('person', { name: 'Ted' }); + * const options = createRecord(person, { namespace: 'api/v1' }); + * const data = await store.request(options); + * ``` + * + * @method createRecord + * @public + * @static + * @for @ember-data/active-record/request + * @param record + * @param options + */ export function createRecord(record: unknown, options: ConstrainedRequestOptions = {}): CreateRequestOptions { const identifier = recordIdentifierFor(record); assert(`Expected to be given a record instance`, identifier); - assert(`Cannot delete a record that does not have an associated type and id.`, isExisting(identifier)); const urlOptions: CreateRecordUrlOptions = { identifier: identifier, @@ -80,13 +173,58 @@ export function createRecord(record: unknown, options: ConstrainedRequestOptions }; } +/** + * Builds request options to update existing record for resources, + * configured for the url, method and header expectations of most ActiveRecord APIs. + * + * **Basic Usage** + * + * ```ts + * import { updateRecord } from '@ember-data/active-record/request'; + * + * const person = this.store.peekRecord('person', '1'); + * person.name = 'Chris'; + * const data = await store.request(updateRecord(person)); + * ``` + * + * **Supplying Options to Modify the Request Behavior** + * + * The following options are supported: + * + * - `patch` - Allows caller to specify whether to use a PATCH request instead of a PUT request, defaults to `false`. + * - `host` - The host to use for the request, defaults to the `host` configured with `setBuildURLConfig`. + * - `namespace` - The namespace to use for the request, defaults to the `namespace` configured with `setBuildURLConfig`. + * - `resourcePath` - The resource path to use for the request, defaults to pluralizing the supplied type + * - `reload` - Whether to forcibly reload the request if it is already in the store, not supplying this + * option will delegate to the store's lifetimes service, defaulting to `false` if none is configured. + * - `backgroundReload` - Whether to reload the request if it is already in the store, but to also resolve the + * promise with the cached value, not supplying this option will delegate to the store's lifetimes service, + * defaulting to `false` if none is configured. + * - `urlParamsSetting` - an object containing options for how to serialize the query params (see `buildQueryParams`) + * + * ```ts + * import { updateRecord } from '@ember-data/active-record/request'; + * + * const person = this.store.peekRecord('person', '1'); + * person.name = 'Chris'; + * const options = updateRecord(person, { patch: true }); + * const data = await store.request(options); + * ``` + * + * @method updateRecord + * @public + * @static + * @for @ember-data/active-record/request + * @param record + * @param options + */ export function updateRecord( record: unknown, options: ConstrainedRequestOptions & { patch?: boolean } = {} ): UpdateRequestOptions { const identifier = recordIdentifierFor(record); assert(`Expected to be given a record instance`, identifier); - assert(`Cannot delete a record that does not have an associated type and id.`, isExisting(identifier)); + assert(`Cannot update a record that does not have an associated type and id.`, isExisting(identifier)); const urlOptions: UpdateRecordUrlOptions = { identifier: identifier, diff --git a/packages/json-api/src/-private/builders/save-record.ts b/packages/json-api/src/-private/builders/save-record.ts index d3acdbe22a5..11f132b84e3 100644 --- a/packages/json-api/src/-private/builders/save-record.ts +++ b/packages/json-api/src/-private/builders/save-record.ts @@ -23,6 +23,58 @@ function isExisting(identifier: StableRecordIdentifier): identifier is StableExi return 'id' in identifier && identifier.id !== null && 'type' in identifier && identifier.type !== null; } +/** + * Builds request options to delete record for resources, + * configured for the url, method and header expectations of most JSON:API APIs. + * + * **Basic Usage** + * + * ```ts + * import { deleteRecord } from '@ember-data/json-api/request'; + * + * const person = this.store.peekRecord('person', '1'); + * + * // mark record as deleted + * store.deleteRecord(person); + * + * // persist deletion + * const data = await store.request(deleteRecord(person)); + * ``` + * + * **Supplying Options to Modify the Request Behavior** + * + * The following options are supported: + * + * - `host` - The host to use for the request, defaults to the `host` configured with `setBuildURLConfig`. + * - `namespace` - The namespace to use for the request, defaults to the `namespace` configured with `setBuildURLConfig`. + * - `resourcePath` - The resource path to use for the request, defaults to pluralizing the supplied type + * - `reload` - Whether to forcibly reload the request if it is already in the store, not supplying this + * option will delegate to the store's lifetimes service, defaulting to `false` if none is configured. + * - `backgroundReload` - Whether to reload the request if it is already in the store, but to also resolve the + * promise with the cached value, not supplying this option will delegate to the store's lifetimes service, + * defaulting to `false` if none is configured. + * - `urlParamsSetting` - an object containing options for how to serialize the query params (see `buildQueryParams`) + * + * ```ts + * import { deleteRecord } from '@ember-data/json-api/request'; + * + * const person = this.store.peekRecord('person', '1'); + * + * // mark record as deleted + * store.deleteRecord(person); + * + * // persist deletion + * const options = deleteRecord(person, { namespace: 'api/v1' }); + * const data = await store.request(options); + * ``` + * + * @method deleteRecord + * @public + * @static + * @for @ember-data/json-api/request + * @param record + * @param options + */ export function deleteRecord(record: unknown, options: ConstrainedRequestOptions = {}): DeleteRequestOptions { const identifier = recordIdentifierFor(record); assert(`Expected to be given a record instance`, identifier); @@ -52,10 +104,51 @@ export function deleteRecord(record: unknown, options: ConstrainedRequestOptions }; } +/** + * Builds request options to create new record for resources, + * configured for the url, method and header expectations of most JSON:API APIs. + * + * **Basic Usage** + * + * ```ts + * import { createRecord } from '@ember-data/json-api/request'; + * + * const person = this.store.createRecord('person', { name: 'Ted' }); + * const data = await store.request(createRecord(person)); + * ``` + * + * **Supplying Options to Modify the Request Behavior** + * + * The following options are supported: + * + * - `host` - The host to use for the request, defaults to the `host` configured with `setBuildURLConfig`. + * - `namespace` - The namespace to use for the request, defaults to the `namespace` configured with `setBuildURLConfig`. + * - `resourcePath` - The resource path to use for the request, defaults to pluralizing the supplied type + * - `reload` - Whether to forcibly reload the request if it is already in the store, not supplying this + * option will delegate to the store's lifetimes service, defaulting to `false` if none is configured. + * - `backgroundReload` - Whether to reload the request if it is already in the store, but to also resolve the + * promise with the cached value, not supplying this option will delegate to the store's lifetimes service, + * defaulting to `false` if none is configured. + * - `urlParamsSetting` - an object containing options for how to serialize the query params (see `buildQueryParams`) + * + * ```ts + * import { createRecord } from '@ember-data/json-api/request'; + * + * const person = this.store.createRecord('person', { name: 'Ted' }); + * const options = createRecord(person, { namespace: 'api/v1' }); + * const data = await store.request(options); + * ``` + * + * @method createRecord + * @public + * @static + * @for @ember-data/json-api/request + * @param record + * @param options + */ export function createRecord(record: unknown, options: ConstrainedRequestOptions = {}): CreateRequestOptions { const identifier = recordIdentifierFor(record); assert(`Expected to be given a record instance`, identifier); - assert(`Cannot delete a record that does not have an associated type and id.`, isExisting(identifier)); const urlOptions: CreateRecordUrlOptions = { identifier: identifier, @@ -81,13 +174,58 @@ export function createRecord(record: unknown, options: ConstrainedRequestOptions }; } +/** + * Builds request options to update existing record for resources, + * configured for the url, method and header expectations of most JSON:API APIs. + * + * **Basic Usage** + * + * ```ts + * import { updateRecord } from '@ember-data/json-api/request'; + * + * const person = this.store.peekRecord('person', '1'); + * person.name = 'Chris'; + * const data = await store.request(updateRecord(person)); + * ``` + * + * **Supplying Options to Modify the Request Behavior** + * + * The following options are supported: + * + * - `patch` - Allows caller to specify whether to use a PATCH request instead of a PUT request, defaults to `false`. + * - `host` - The host to use for the request, defaults to the `host` configured with `setBuildURLConfig`. + * - `namespace` - The namespace to use for the request, defaults to the `namespace` configured with `setBuildURLConfig`. + * - `resourcePath` - The resource path to use for the request, defaults to pluralizing the supplied type + * - `reload` - Whether to forcibly reload the request if it is already in the store, not supplying this + * option will delegate to the store's lifetimes service, defaulting to `false` if none is configured. + * - `backgroundReload` - Whether to reload the request if it is already in the store, but to also resolve the + * promise with the cached value, not supplying this option will delegate to the store's lifetimes service, + * defaulting to `false` if none is configured. + * - `urlParamsSetting` - an object containing options for how to serialize the query params (see `buildQueryParams`) + * + * ```ts + * import { updateRecord } from '@ember-data/json-api/request'; + * + * const person = this.store.peekRecord('person', '1'); + * person.name = 'Chris'; + * const options = updateRecord(person, { patch: true }); + * const data = await store.request(options); + * ``` + * + * @method updateRecord + * @public + * @static + * @for @ember-data/json-api/request + * @param record + * @param options + */ export function updateRecord( record: unknown, options: ConstrainedRequestOptions & { patch?: boolean } = {} ): UpdateRequestOptions { const identifier = recordIdentifierFor(record); assert(`Expected to be given a record instance`, identifier); - assert(`Cannot delete a record that does not have an associated type and id.`, isExisting(identifier)); + assert(`Cannot update a record that does not have an associated type and id.`, isExisting(identifier)); const urlOptions: UpdateRecordUrlOptions = { identifier: identifier, diff --git a/packages/json-api/src/-private/cache.ts b/packages/json-api/src/-private/cache.ts index 140b3bc3d96..b03442389f5 100644 --- a/packages/json-api/src/-private/cache.ts +++ b/packages/json-api/src/-private/cache.ts @@ -1117,6 +1117,7 @@ export default class JSONAPICache implements Cache { if (cached.isNew) { // > Note: Graph removal handled by unloadRecord + cached.isDeletionCommitted = true; cached.isDeleted = true; cached.isNew = false; } diff --git a/packages/model/src/-private/model.d.ts b/packages/model/src/-private/model.d.ts index 58939a99d8b..3263a5acff1 100644 --- a/packages/model/src/-private/model.d.ts +++ b/packages/model/src/-private/model.d.ts @@ -10,6 +10,7 @@ import type BelongsToReference from './references/belongs-to'; import type { StableRecordIdentifier } from '@ember-data/types/q/identifier'; import type { LegacySupport } from './legacy-relationships-support'; import type { Cache } from '@ember-data/types/q/cache'; +import type RecordState from './record-state'; export type ModelCreateArgs = { _createProps: Record; @@ -26,6 +27,8 @@ export type ModelCreateArgs = { class Model extends EmberObject { store: Store; errors: Errors; + currentState: RecordState; + adapterError?: Error; toString(): string; save(): Promise; hasMany(key: string): HasManyReference; diff --git a/packages/model/src/-private/record-state.ts b/packages/model/src/-private/record-state.ts index c31d238b8f3..47fa1530085 100644 --- a/packages/model/src/-private/record-state.ts +++ b/packages/model/src/-private/record-state.ts @@ -202,6 +202,7 @@ export default class RecordState { this._errorRequests = []; this._lastError = null; this.isSaving = false; + this.notify('isDirty'); notifyErrorsStateChanged(this); break; } @@ -249,6 +250,7 @@ export default class RecordState { (identifier: StableRecordIdentifier, type: NotificationType, key?: string) => { switch (type) { case 'state': + this.notify('isSaved'); this.notify('isNew'); this.notify('isDeleted'); this.notify('isDirty'); @@ -371,10 +373,10 @@ export default class RecordState { @tagged get isDirty() { let rd = this.cache; - if (rd.isDeletionCommitted(this.identifier) || (this.isDeleted && this.isNew)) { + if (this.isEmpty || rd.isDeletionCommitted(this.identifier) || (this.isDeleted && this.isNew)) { return false; } - return this.isNew || rd.hasChangedAttrs(this.identifier); + return this.isDeleted || this.isNew || rd.hasChangedAttrs(this.identifier); } @tagged @@ -454,7 +456,7 @@ export default class RecordState { return ''; // deleted substates - } else if (this.isDeleted) { + } else if (this.isDirty && this.isDeleted) { return 'deleted'; // loaded.created substates diff --git a/packages/request/src/-private/types.ts b/packages/request/src/-private/types.ts index 39389cc99fb..c31cb5d4ad2 100644 --- a/packages/request/src/-private/types.ts +++ b/packages/request/src/-private/types.ts @@ -36,7 +36,7 @@ interface Request { /* Returns the URL of request as a string. */ url?: string; } -export type ImmutableHeaders = Headers & { clone(): Headers; toJSON(): [string, string][] }; +export type ImmutableHeaders = Headers & { clone?(): Headers; toJSON(): [string, string][] }; export interface GodContext { controller: AbortController; response: ResponseInfo | null; @@ -150,7 +150,7 @@ export interface ImmutableRequestInfo { /* Returns the kind of resource requested by request, e.g., "document" or "script". */ readonly destination?: RequestDestination; /* Returns a Headers object consisting of the headers associated with request. Note that headers added in the network layer by the user agent will not be accounted for in this object, e.g., the "Host" header. */ - readonly headers?: Headers & { clone(): Headers }; + readonly headers?: Headers & { clone?(): Headers }; /* Returns request's subresource integrity metadata, which is a cryptographic hash of the resource being fetched. Its value consists of multiple hashes separated by whitespace. [SRI] */ readonly integrity?: string; /* Returns a boolean indicating whether or not request can outlive the global in which it was created. */ diff --git a/packages/rest/src/-private/builders/save-record.ts b/packages/rest/src/-private/builders/save-record.ts index 94e7e645e2d..018d62d8b33 100644 --- a/packages/rest/src/-private/builders/save-record.ts +++ b/packages/rest/src/-private/builders/save-record.ts @@ -24,6 +24,58 @@ function isExisting(identifier: StableRecordIdentifier): identifier is StableExi return 'id' in identifier && identifier.id !== null && 'type' in identifier && identifier.type !== null; } +/** + * Builds request options to delete record for resources, + * configured for the url, method and header expectations of REST APIs. + * + * **Basic Usage** + * + * ```ts + * import { deleteRecord } from '@ember-data/rest/request'; + * + * const person = this.store.peekRecord('person', '1'); + * + * // mark record as deleted + * store.deleteRecord(person); + * + * // persist deletion + * const data = await store.request(deleteRecord(person)); + * ``` + * + * **Supplying Options to Modify the Request Behavior** + * + * The following options are supported: + * + * - `host` - The host to use for the request, defaults to the `host` configured with `setBuildURLConfig`. + * - `namespace` - The namespace to use for the request, defaults to the `namespace` configured with `setBuildURLConfig`. + * - `resourcePath` - The resource path to use for the request, defaults to pluralizing the supplied type + * - `reload` - Whether to forcibly reload the request if it is already in the store, not supplying this + * option will delegate to the store's lifetimes service, defaulting to `false` if none is configured. + * - `backgroundReload` - Whether to reload the request if it is already in the store, but to also resolve the + * promise with the cached value, not supplying this option will delegate to the store's lifetimes service, + * defaulting to `false` if none is configured. + * - `urlParamsSetting` - an object containing options for how to serialize the query params (see `buildQueryParams`) + * + * ```ts + * import { deleteRecord } from '@ember-data/rest/request'; + * + * const person = this.store.peekRecord('person', '1'); + * + * // mark record as deleted + * store.deleteRecord(person); + * + * // persist deletion + * const options = deleteRecord(person, { namespace: 'api/v1' }); + * const data = await store.request(options); + * ``` + * + * @method deleteRecord + * @public + * @static + * @for @ember-data/rest/request + * @param record + * @param options + */ export function deleteRecord(record: unknown, options: ConstrainedRequestOptions = {}): DeleteRequestOptions { const identifier = recordIdentifierFor(record); assert(`Expected to be given a record instance`, identifier); @@ -52,10 +104,51 @@ export function deleteRecord(record: unknown, options: ConstrainedRequestOptions }; } +/** + * Builds request options to create new record for resources, + * configured for the url, method and header expectations of most REST APIs. + * + * **Basic Usage** + * + * ```ts + * import { createRecord } from '@ember-data/rest/request'; + * + * const person = this.store.createRecord('person', { name: 'Ted' }); + * const data = await store.request(createRecord(person)); + * ``` + * + * **Supplying Options to Modify the Request Behavior** + * + * The following options are supported: + * + * - `host` - The host to use for the request, defaults to the `host` configured with `setBuildURLConfig`. + * - `namespace` - The namespace to use for the request, defaults to the `namespace` configured with `setBuildURLConfig`. + * - `resourcePath` - The resource path to use for the request, defaults to pluralizing the supplied type + * - `reload` - Whether to forcibly reload the request if it is already in the store, not supplying this + * option will delegate to the store's lifetimes service, defaulting to `false` if none is configured. + * - `backgroundReload` - Whether to reload the request if it is already in the store, but to also resolve the + * promise with the cached value, not supplying this option will delegate to the store's lifetimes service, + * defaulting to `false` if none is configured. + * - `urlParamsSetting` - an object containing options for how to serialize the query params (see `buildQueryParams`) + * + * ```ts + * import { createRecord } from '@ember-data/rest/request'; + * + * const person = this.store.createRecord('person', { name: 'Ted' }); + * const options = createRecord(person, { namespace: 'api/v1' }); + * const data = await store.request(options); + * ``` + * + * @method createRecord + * @public + * @static + * @for @ember-data/rest/request + * @param record + * @param options + */ export function createRecord(record: unknown, options: ConstrainedRequestOptions = {}): CreateRequestOptions { const identifier = recordIdentifierFor(record); assert(`Expected to be given a record instance`, identifier); - assert(`Cannot delete a record that does not have an associated type and id.`, isExisting(identifier)); const urlOptions: CreateRecordUrlOptions = { identifier: identifier, @@ -80,13 +173,58 @@ export function createRecord(record: unknown, options: ConstrainedRequestOptions }; } +/** + * Builds request options to update existing record for resources, + * configured for the url, method and header expectations of most REST APIs. + * + * **Basic Usage** + * + * ```ts + * import { updateRecord } from '@ember-data/rest/request'; + * + * const person = this.store.peekRecord('person', '1'); + * person.name = 'Chris'; + * const data = await store.request(updateRecord(person)); + * ``` + * + * **Supplying Options to Modify the Request Behavior** + * + * The following options are supported: + * + * - `patch` - Allows caller to specify whether to use a PATCH request instead of a PUT request, defaults to `false`. + * - `host` - The host to use for the request, defaults to the `host` configured with `setBuildURLConfig`. + * - `namespace` - The namespace to use for the request, defaults to the `namespace` configured with `setBuildURLConfig`. + * - `resourcePath` - The resource path to use for the request, defaults to pluralizing the supplied type + * - `reload` - Whether to forcibly reload the request if it is already in the store, not supplying this + * option will delegate to the store's lifetimes service, defaulting to `false` if none is configured. + * - `backgroundReload` - Whether to reload the request if it is already in the store, but to also resolve the + * promise with the cached value, not supplying this option will delegate to the store's lifetimes service, + * defaulting to `false` if none is configured. + * - `urlParamsSetting` - an object containing options for how to serialize the query params (see `buildQueryParams`) + * + * ```ts + * import { updateRecord } from '@ember-data/rest/request'; + * + * const person = this.store.peekRecord('person', '1'); + * person.name = 'Chris'; + * const options = updateRecord(person, { patch: true }); + * const data = await store.request(options); + * ``` + * + * @method updateRecord + * @public + * @static + * @for @ember-data/rest/request + * @param record + * @param options + */ export function updateRecord( record: unknown, options: ConstrainedRequestOptions & { patch?: boolean } = {} ): UpdateRequestOptions { const identifier = recordIdentifierFor(record); assert(`Expected to be given a record instance`, identifier); - assert(`Cannot delete a record that does not have an associated type and id.`, isExisting(identifier)); + assert(`Cannot update a record that does not have an associated type and id.`, isExisting(identifier)); const urlOptions: UpdateRecordUrlOptions = { identifier: identifier, diff --git a/packages/store/src/-private/cache-handler.ts b/packages/store/src/-private/cache-handler.ts index 0fcae63a1a0..2fe4fb4b510 100644 --- a/packages/store/src/-private/cache-handler.ts +++ b/packages/store/src/-private/cache-handler.ts @@ -1,3 +1,5 @@ +import { assert } from '@ember/debug'; + import type { Future, Handler, @@ -7,14 +9,16 @@ import type { StructuredErrorDocument, } from '@ember-data/request/-private/types'; import type Store from '@ember-data/store'; -import { +import type { CollectionResourceDataDocument, ResourceDataDocument, ResourceErrorDocument, } from '@ember-data/types/cache/document'; -import { StableDocumentIdentifier } from '@ember-data/types/cache/identifier'; -import { ResourceIdentifierObject } from '@ember-data/types/q/ember-data-json-api'; -import { RecordInstance } from '@ember-data/types/q/record-instance'; +import type { StableDocumentIdentifier } from '@ember-data/types/cache/identifier'; +import type { ResourceIdentifierObject } from '@ember-data/types/q/ember-data-json-api'; +import type { JsonApiError } from '@ember-data/types/q/record-data-json-api'; +import type { RecordInstance } from '@ember-data/types/q/record-instance'; +import type { CreateRequestOptions, DeleteRequestOptions, UpdateRequestOptions } from '@ember-data/types/request'; import { Document } from './document'; @@ -151,6 +155,8 @@ function maybeUpdateUiObjects( } } +const MUTATION_OPS = new Set(['createRecord', 'updateRecord', 'deleteRecord']); + function calcShouldFetch( store: Store, request: StoreRequestInfo, @@ -159,6 +165,7 @@ function calcShouldFetch( ): boolean { const { cacheOptions } = request; return ( + (request.op && MUTATION_OPS.has(request.op)) || cacheOptions?.reload || !hasCachedValue || (store.lifetimes && identifier ? store.lifetimes.isHardExpired(identifier) : false) @@ -179,6 +186,12 @@ function calcShouldBackgroundFetch( ); } +function isMutation( + request: Partial +): request is UpdateRequestOptions | CreateRequestOptions | DeleteRequestOptions { + return Boolean(request.op && MUTATION_OPS.has(request.op)); +} + function fetchContentAndHydrate( next: NextFn, context: StoreRequestContext, @@ -189,13 +202,26 @@ function fetchContentAndHydrate( const { store } = context.request; const shouldHydrate: boolean = (context.request[Symbol.for('ember-data:enable-hydration')] as boolean | undefined) || false; - return next(context.request).then( + + let isMut = false; + if (isMutation(context.request)) { + isMut = true; + const record = context.request.data?.record; + assert(`Expected to receive a list of records included in the ${context.request.op} request`, record); + store.cache.willCommit(record, context); + } + + const promise = next(context.request).then( (document) => { store.requestManager._pending.delete(context.id); store._enableAsyncFlush = true; let response: ResourceDataDocument; store._join(() => { - response = store.cache.put(document) as ResourceDataDocument; + if (isMutation(context.request)) { + response = store.cache.didCommit(context.request.data.record, document) as ResourceDataDocument; + } else { + response = store.cache.put(document) as ResourceDataDocument; + } response = maybeUpdateUiObjects( store, context.request, @@ -219,34 +245,60 @@ function fetchContentAndHydrate( } store.requestManager._pending.delete(context.id); store._enableAsyncFlush = true; - let response: ResourceErrorDocument; + let response: ResourceErrorDocument | undefined; store._join(() => { - response = store.cache.put(error) as ResourceErrorDocument; - response = maybeUpdateUiObjects( - store, - context.request, - { shouldHydrate, shouldFetch, shouldBackgroundFetch, identifier }, - response, - false - ); + if (isMutation(context.request)) { + // TODO similar to didCommit we should spec this to be similar to cache.put for handling full response + // currently we let the response remain undefiend. + const errors = + error && + error.content && + typeof error.content === 'object' && + 'errors' in error.content && + Array.isArray(error.content.errors) + ? (error.content.errors as JsonApiError[]) + : undefined; + store.cache.commitWasRejected(context.request.data.record, errors); + // re-throw the original error to preserve `errors` property. + throw error; + } else { + response = store.cache.put(error) as ResourceErrorDocument; + response = maybeUpdateUiObjects( + store, + context.request, + { shouldHydrate, shouldFetch, shouldBackgroundFetch, identifier }, + response, + false + ); + } }); store._enableAsyncFlush = null; if (!shouldBackgroundFetch) { const newError = cloneError(error); - newError.content = response!; + newError.content = response; throw newError; } else { store.notifications._flush(); } } ) as Promise; + + if (!isMut) { + return promise; + } + assert(`Expected a mutation`, isMutation(context.request)); + + // for mutations we need to enqueue the promise with the requestStateService + return store._requestCache._enqueue(promise, { + data: [{ op: 'saveRecord', recordIdentifier: context.request.data.record, options: undefined }], + }); } function cloneError(error: Error & { error: string | object }) { - const cloned: Error & { error: string | object; content: object } = new Error(error.message) as Error & { + const cloned: Error & { error: string | object; content?: object } = new Error(error.message) as Error & { error: string | object; - content: object; + content?: object; }; cloned.stack = error.stack; cloned.error = error.error; diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 9daffef0141..8fc49976e67 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -1592,25 +1592,34 @@ importers: version: 7.22.15 '@ember-data/active-record': specifier: workspace:5.4.0-alpha.14 - version: file:packages/active-record(@babel/core@7.22.17)(@ember-data/store@packages+store)(@ember/string@3.1.1)(ember-inflector@4.0.2) + version: file:packages/active-record(@babel/core@7.22.17)(@ember-data/store@5.4.0-alpha.14)(@ember/string@3.1.1)(ember-inflector@4.0.2) '@ember-data/graph': specifier: workspace:5.4.0-alpha.14 version: link:../../packages/graph '@ember-data/json-api': specifier: workspace:5.4.0-alpha.14 - version: file:packages/json-api(@babel/core@7.22.17)(@ember-data/graph@packages+graph)(@ember-data/request-utils@5.4.0-alpha.14)(@ember-data/store@packages+store)(ember-inflector@4.0.2) + version: file:packages/json-api(@babel/core@7.22.17)(@ember-data/graph@packages+graph)(@ember-data/request-utils@5.4.0-alpha.14)(@ember-data/store@5.4.0-alpha.14)(ember-inflector@4.0.2) + '@ember-data/legacy-compat': + specifier: workspace:5.4.0-alpha.14 + version: file:packages/legacy-compat(@babel/core@7.22.17)(@ember-data/graph@packages+graph)(@ember-data/json-api@5.4.0-alpha.14)(@ember-data/request@5.4.0-alpha.14) + '@ember-data/model': + specifier: workspace:5.4.0-alpha.14 + version: file:packages/model(@babel/core@7.22.17)(@ember-data/graph@packages+graph)(@ember-data/json-api@5.4.0-alpha.14)(@ember-data/legacy-compat@5.4.0-alpha.14)(@ember-data/store@5.4.0-alpha.14)(@ember-data/tracking@packages+tracking)(@ember/string@3.1.1)(ember-inflector@4.0.2)(ember-source@5.2.0) '@ember-data/private-build-infra': specifier: workspace:5.4.0-alpha.14 version: file:packages/private-build-infra + '@ember-data/request': + specifier: workspace:5.4.0-alpha.14 + version: file:packages/request(@babel/core@7.22.17) '@ember-data/request-utils': specifier: workspace:5.4.0-alpha.14 version: file:packages/request-utils(@babel/core@7.22.17) '@ember-data/rest': specifier: workspace:5.4.0-alpha.14 - version: file:packages/rest(@babel/core@7.22.17)(@ember-data/store@packages+store)(@ember/string@3.1.1)(ember-inflector@4.0.2) + version: file:packages/rest(@babel/core@7.22.17)(@ember-data/store@5.4.0-alpha.14)(@ember/string@3.1.1)(ember-inflector@4.0.2) '@ember-data/store': specifier: workspace:5.4.0-alpha.14 - version: link:../../packages/store + version: file:packages/store(@babel/core@7.22.17)(@ember-data/tracking@packages+tracking)(@ember/string@3.1.1)(@glimmer/tracking@1.1.2)(ember-source@5.2.0) '@ember-data/tracking': specifier: workspace:5.4.0-alpha.14 version: link:../../packages/tracking @@ -1706,12 +1715,20 @@ importers: injected: true '@ember-data/json-api': injected: true + '@ember-data/legacy-compat': + injected: true + '@ember-data/model': + injected: true '@ember-data/private-build-infra': injected: true + '@ember-data/request': + injected: true '@ember-data/request-utils': injected: true '@ember-data/rest': injected: true + '@ember-data/store': + injected: true '@ember-data/unpublished-test-infra': injected: true @@ -2451,7 +2468,7 @@ importers: version: file:packages/debug(@ember-data/store@5.4.0-alpha.14)(@ember/string@3.1.1) '@ember-data/legacy-compat': specifier: workspace:5.4.0-alpha.14 - version: file:packages/legacy-compat(@babel/core@7.22.17)(@ember-data/graph@5.4.0-alpha.14)(@ember-data/json-api@5.4.0-alpha.14)(@ember-data/request@5.4.0-alpha.14) + version: file:packages/legacy-compat(@babel/core@7.22.17)(@ember-data/graph@packages+graph)(@ember-data/json-api@5.4.0-alpha.14)(@ember-data/request@5.4.0-alpha.14) '@ember-data/model': specifier: workspace:5.4.0-alpha.14 version: file:packages/model(@babel/core@7.22.17)(@ember-data/debug@5.4.0-alpha.14)(@ember-data/legacy-compat@5.4.0-alpha.14)(@ember-data/store@5.4.0-alpha.14)(@ember-data/tracking@5.4.0-alpha.14)(@ember/string@3.1.1)(ember-inflector@4.0.2)(ember-source@5.2.0) @@ -16333,7 +16350,7 @@ packages: - uglify-js - webpack-cli - file:packages/active-record(@babel/core@7.22.17)(@ember-data/store@packages+store)(@ember/string@3.1.1)(ember-inflector@4.0.2): + file:packages/active-record(@babel/core@7.22.17)(@ember-data/store@5.4.0-alpha.14)(@ember/string@3.1.1)(ember-inflector@4.0.2): resolution: {directory: packages/active-record, type: directory} id: file:packages/active-record name: '@ember-data/active-record' @@ -16343,7 +16360,7 @@ packages: '@ember/string': 3.1.1 ember-inflector: ^4.0.2 dependencies: - '@ember-data/store': link:packages/store + '@ember-data/store': file:packages/store(@babel/core@7.22.17)(@ember-data/tracking@packages+tracking)(@ember/string@3.1.1)(@glimmer/tracking@1.1.2)(ember-source@5.2.0) '@ember/string': 3.1.1(@babel/core@7.22.17) ember-cli-babel: 8.0.0(patch_hash=u45mf2ptxi5n6ldhiqont5zk3y)(@babel/core@7.22.17) ember-inflector: 4.0.2(@babel/core@7.22.17) @@ -16465,7 +16482,7 @@ packages: - '@glint/template' - supports-color - file:packages/json-api(@babel/core@7.22.17)(@ember-data/graph@packages+graph)(@ember-data/request-utils@5.4.0-alpha.14)(@ember-data/store@packages+store)(ember-inflector@4.0.2): + file:packages/json-api(@babel/core@7.22.17)(@ember-data/graph@packages+graph)(@ember-data/request-utils@5.4.0-alpha.14)(@ember-data/store@5.4.0-alpha.14)(ember-inflector@4.0.2): resolution: {directory: packages/json-api, type: directory} id: file:packages/json-api name: '@ember-data/json-api' @@ -16479,7 +16496,7 @@ packages: '@ember-data/graph': link:packages/graph '@ember-data/private-build-infra': file:packages/private-build-infra '@ember-data/request-utils': file:packages/request-utils(@babel/core@7.22.17) - '@ember-data/store': link:packages/store + '@ember-data/store': file:packages/store(@babel/core@7.22.17)(@ember-data/tracking@packages+tracking)(@ember/string@3.1.1)(@glimmer/tracking@1.1.2)(ember-source@5.2.0) '@ember/edition-utils': 1.2.0 '@embroider/macros': 1.13.1(@babel/core@7.22.17) ember-cli-babel: 8.0.0(patch_hash=u45mf2ptxi5n6ldhiqont5zk3y)(@babel/core@7.22.17) @@ -16516,6 +16533,33 @@ packages: - '@glint/template' - supports-color + file:packages/legacy-compat(@babel/core@7.22.17)(@ember-data/graph@packages+graph)(@ember-data/json-api@5.4.0-alpha.14)(@ember-data/request@5.4.0-alpha.14): + resolution: {directory: packages/legacy-compat, type: directory} + id: file:packages/legacy-compat + name: '@ember-data/legacy-compat' + engines: {node: 16.* || >= 18} + peerDependencies: + '@ember-data/graph': workspace:5.4.0-alpha.14 + '@ember-data/json-api': workspace:5.4.0-alpha.14 + '@ember-data/request': workspace:5.4.0-alpha.14 + peerDependenciesMeta: + '@ember-data/graph': + optional: true + '@ember-data/json-api': + optional: true + dependencies: + '@ember-data/graph': link:packages/graph + '@ember-data/json-api': file:packages/json-api(@babel/core@7.22.17)(@ember-data/graph@packages+graph)(@ember-data/request-utils@5.4.0-alpha.14)(@ember-data/store@5.4.0-alpha.14)(ember-inflector@4.0.2) + '@ember-data/private-build-infra': file:packages/private-build-infra + '@ember-data/request': file:packages/request(@babel/core@7.22.17) + '@embroider/macros': 1.13.1(@babel/core@7.22.17) + ember-cli-babel: 8.0.0(patch_hash=u45mf2ptxi5n6ldhiqont5zk3y)(@babel/core@7.22.17) + transitivePeerDependencies: + - '@babel/core' + - '@glint/template' + - supports-color + dev: true + file:packages/model(@babel/core@7.22.17)(@ember-data/debug@5.4.0-alpha.14)(@ember-data/graph@5.4.0-alpha.14)(@ember-data/json-api@5.4.0-alpha.14)(@ember-data/legacy-compat@5.4.0-alpha.14)(@ember-data/store@5.4.0-alpha.14)(@ember-data/tracking@5.4.0-alpha.14)(@ember/string@3.1.1)(ember-inflector@4.0.2)(ember-source@5.2.0): resolution: {directory: packages/model, type: directory} id: file:packages/model @@ -16583,7 +16627,7 @@ packages: optional: true dependencies: '@ember-data/debug': file:packages/debug(@ember-data/store@5.4.0-alpha.14)(@ember/string@3.1.1) - '@ember-data/legacy-compat': file:packages/legacy-compat(@babel/core@7.22.17)(@ember-data/graph@5.4.0-alpha.14)(@ember-data/json-api@5.4.0-alpha.14)(@ember-data/request@5.4.0-alpha.14) + '@ember-data/legacy-compat': file:packages/legacy-compat(@babel/core@7.22.17)(@ember-data/graph@packages+graph)(@ember-data/json-api@5.4.0-alpha.14)(@ember-data/request@5.4.0-alpha.14) '@ember-data/private-build-infra': file:packages/private-build-infra '@ember-data/store': file:packages/store(@babel/core@7.22.17)(@ember-data/tracking@5.4.0-alpha.14)(@ember/string@3.1.1)(@glimmer/tracking@1.1.2)(ember-source@5.2.0) '@ember-data/tracking': file:packages/tracking(@babel/core@7.22.17) @@ -16603,6 +16647,50 @@ packages: - supports-color dev: true + file:packages/model(@babel/core@7.22.17)(@ember-data/graph@packages+graph)(@ember-data/json-api@5.4.0-alpha.14)(@ember-data/legacy-compat@5.4.0-alpha.14)(@ember-data/store@5.4.0-alpha.14)(@ember-data/tracking@packages+tracking)(@ember/string@3.1.1)(ember-inflector@4.0.2)(ember-source@5.2.0): + resolution: {directory: packages/model, type: directory} + id: file:packages/model + name: '@ember-data/model' + engines: {node: 16.* || >= 18.*} + peerDependencies: + '@ember-data/debug': workspace:5.4.0-alpha.14 + '@ember-data/graph': workspace:5.4.0-alpha.14 + '@ember-data/json-api': workspace:5.4.0-alpha.14 + '@ember-data/legacy-compat': workspace:5.4.0-alpha.14 + '@ember-data/store': workspace:5.4.0-alpha.14 + '@ember-data/tracking': workspace:5.4.0-alpha.14 + '@ember/string': 3.1.1 + ember-inflector: ^4.0.2 + peerDependenciesMeta: + '@ember-data/debug': + optional: true + '@ember-data/graph': + optional: true + '@ember-data/json-api': + optional: true + dependencies: + '@ember-data/graph': link:packages/graph + '@ember-data/json-api': file:packages/json-api(@babel/core@7.22.17)(@ember-data/graph@packages+graph)(@ember-data/request-utils@5.4.0-alpha.14)(@ember-data/store@5.4.0-alpha.14)(ember-inflector@4.0.2) + '@ember-data/legacy-compat': file:packages/legacy-compat(@babel/core@7.22.17)(@ember-data/graph@packages+graph)(@ember-data/json-api@5.4.0-alpha.14)(@ember-data/request@5.4.0-alpha.14) + '@ember-data/private-build-infra': file:packages/private-build-infra + '@ember-data/store': file:packages/store(@babel/core@7.22.17)(@ember-data/tracking@packages+tracking)(@ember/string@3.1.1)(@glimmer/tracking@1.1.2)(ember-source@5.2.0) + '@ember-data/tracking': link:packages/tracking + '@ember/edition-utils': 1.2.0 + '@ember/string': 3.1.1(@babel/core@7.22.17) + '@embroider/macros': 1.13.1(@babel/core@7.22.17) + ember-cached-decorator-polyfill: 1.0.2(@babel/core@7.22.17)(ember-source@5.2.0) + ember-cli-babel: 8.0.0(patch_hash=u45mf2ptxi5n6ldhiqont5zk3y)(@babel/core@7.22.17) + ember-cli-string-utils: 1.1.0 + ember-cli-test-info: 1.0.0 + ember-inflector: 4.0.2(@babel/core@7.22.17) + inflection: 2.0.1 + transitivePeerDependencies: + - '@babel/core' + - '@glint/template' + - ember-source + - supports-color + dev: true + file:packages/model(@babel/core@7.22.17)(@ember-data/legacy-compat@packages+legacy-compat)(@ember-data/store@5.4.0-alpha.14)(@ember-data/tracking@5.4.0-alpha.14)(@ember/string@3.1.1)(ember-inflector@4.0.2)(ember-source@5.2.0): resolution: {directory: packages/model, type: directory} id: file:packages/model @@ -16744,7 +16832,7 @@ packages: - '@babel/core' - supports-color - file:packages/rest(@babel/core@7.22.17)(@ember-data/store@packages+store)(@ember/string@3.1.1)(ember-inflector@4.0.2): + file:packages/rest(@babel/core@7.22.17)(@ember-data/store@5.4.0-alpha.14)(@ember/string@3.1.1)(ember-inflector@4.0.2): resolution: {directory: packages/rest, type: directory} id: file:packages/rest name: '@ember-data/rest' @@ -16754,7 +16842,7 @@ packages: '@ember/string': 3.1.1 ember-inflector: ^4.0.2 dependencies: - '@ember-data/store': link:packages/store + '@ember-data/store': file:packages/store(@babel/core@7.22.17)(@ember-data/tracking@packages+tracking)(@ember/string@3.1.1)(@glimmer/tracking@1.1.2)(ember-source@5.2.0) '@ember/string': 3.1.1(@babel/core@7.22.17) ember-cli-babel: 8.0.0(patch_hash=u45mf2ptxi5n6ldhiqont5zk3y)(@babel/core@7.22.17) ember-inflector: 4.0.2(@babel/core@7.22.17) @@ -16806,6 +16894,30 @@ packages: - ember-source - supports-color + file:packages/store(@babel/core@7.22.17)(@ember-data/tracking@packages+tracking)(@ember/string@3.1.1)(@glimmer/tracking@1.1.2)(ember-source@5.2.0): + resolution: {directory: packages/store, type: directory} + id: file:packages/store + name: '@ember-data/store' + engines: {node: 16.* || >= 18.*} + peerDependencies: + '@ember-data/tracking': workspace:5.4.0-alpha.14 + '@ember/string': 3.1.1 + '@glimmer/tracking': ^1.1.2 + dependencies: + '@ember-data/private-build-infra': file:packages/private-build-infra + '@ember-data/tracking': link:packages/tracking + '@ember/string': 3.1.1(@babel/core@7.22.17) + '@embroider/macros': 1.13.1(@babel/core@7.22.17) + '@glimmer/tracking': 1.1.2 + ember-cached-decorator-polyfill: 1.0.2(@babel/core@7.22.17)(ember-source@5.2.0) + ember-cli-babel: 8.0.0(patch_hash=u45mf2ptxi5n6ldhiqont5zk3y)(@babel/core@7.22.17) + transitivePeerDependencies: + - '@babel/core' + - '@glint/template' + - ember-source + - supports-color + dev: true + file:packages/tracking(@babel/core@7.22.17): resolution: {directory: packages/tracking, type: directory} id: file:packages/tracking diff --git a/tests/builders/app/models/user-setting.ts b/tests/builders/app/models/user-setting.ts new file mode 100644 index 00000000000..e8f3511d0a0 --- /dev/null +++ b/tests/builders/app/models/user-setting.ts @@ -0,0 +1,5 @@ +import Model, { attr } from '@ember-data/model'; + +export default class UserSetting extends Model { + @attr declare name: string; +} diff --git a/tests/builders/app/services/store.ts b/tests/builders/app/services/store.ts new file mode 100644 index 00000000000..41ac00034c8 --- /dev/null +++ b/tests/builders/app/services/store.ts @@ -0,0 +1,38 @@ +import JSONAPICache from '@ember-data/json-api'; +import type Model from '@ember-data/model'; +import { instantiateRecord, teardownRecord } from '@ember-data/model'; +import { buildSchema, modelFor } from '@ember-data/model/hooks'; +import RequestManager from '@ember-data/request'; +import Fetch from '@ember-data/request/fetch'; +import DataStore, { CacheHandler } from '@ember-data/store'; +import type { Cache } from '@ember-data/types/cache/cache'; +import type { CacheCapabilitiesManager } from '@ember-data/types/q/cache-store-wrapper'; +import type { StableRecordIdentifier } from '@ember-data/types/q/identifier'; + +export default class Store extends DataStore { + constructor(args: unknown) { + super(args); + + const manager = (this.requestManager = new RequestManager()); + manager.use([Fetch]); + manager.useCache(CacheHandler); + + this.registerSchema(buildSchema(this)); + } + + createCache(capabilities: CacheCapabilitiesManager): Cache { + return new JSONAPICache(capabilities); + } + + instantiateRecord(identifier: StableRecordIdentifier, createRecordArgs: { [key: string]: unknown }): unknown { + return instantiateRecord.call(this, identifier, createRecordArgs); + } + + teardownRecord(record: Model): void { + return teardownRecord.call(this, record); + } + + modelFor(type: string) { + return modelFor.call(this, type); + } +} diff --git a/tests/builders/package.json b/tests/builders/package.json index 2f2debfa963..7ca8e3a4df8 100644 --- a/tests/builders/package.json +++ b/tests/builders/package.json @@ -25,6 +25,18 @@ "@ember-data/rest": { "injected": true }, + "@ember-data/store": { + "injected": true + }, + "@ember-data/model": { + "injected": true + }, + "@ember-data/legacy-compat": { + "injected": true + }, + "@ember-data/request": { + "injected": true + }, "@ember-data/request-utils": { "injected": true }, @@ -44,7 +56,10 @@ "@ember-data/active-record": "workspace:5.4.0-alpha.14", "@ember-data/graph": "workspace:5.4.0-alpha.14", "@ember-data/json-api": "workspace:5.4.0-alpha.14", + "@ember-data/model": "workspace:5.4.0-alpha.14", + "@ember-data/legacy-compat": "workspace:5.4.0-alpha.14", "@ember-data/private-build-infra": "workspace:5.4.0-alpha.14", + "@ember-data/request": "workspace:5.4.0-alpha.14", "@ember-data/request-utils": "workspace:5.4.0-alpha.14", "@ember-data/rest": "workspace:5.4.0-alpha.14", "@ember-data/store": "workspace:5.4.0-alpha.14", @@ -89,4 +104,4 @@ "extends": "../../package.json" }, "packageManager": "pnpm@8.7.5" -} \ No newline at end of file +} diff --git a/tests/builders/tests/integration/create-record-test.ts b/tests/builders/tests/integration/create-record-test.ts new file mode 100644 index 00000000000..acf619e17ab --- /dev/null +++ b/tests/builders/tests/integration/create-record-test.ts @@ -0,0 +1,259 @@ +import { module, test } from 'qunit'; + +import { setupTest } from 'ember-qunit'; + +import JSONAPICache from '@ember-data/json-api'; +import { createRecord } from '@ember-data/json-api/request'; +import Model, { attr, instantiateRecord, teardownRecord } from '@ember-data/model'; +import { buildSchema, modelFor } from '@ember-data/model/hooks'; +import RequestManager from '@ember-data/request'; +import type { Future, Handler, RequestContext } from '@ember-data/request/-private/types'; +import { setBuildURLConfig } from '@ember-data/request-utils'; +import DataStore, { CacheHandler, recordIdentifierFor } from '@ember-data/store'; +import type { Cache } from '@ember-data/types/cache/cache'; +import { SingleResourceDataDocument, StructuredDataDocument } from '@ember-data/types/cache/document'; +import type { CacheCapabilitiesManager } from '@ember-data/types/q/cache-store-wrapper'; +import { SingleResourceDocument } from '@ember-data/types/q/ember-data-json-api'; +import type { StableExistingRecordIdentifier, StableRecordIdentifier } from '@ember-data/types/q/identifier'; +import { JsonApiError } from '@ember-data/types/q/record-data-json-api'; + +class TestStore extends DataStore { + constructor(args: unknown) { + super(args); + + const manager = (this.requestManager = new RequestManager()); + manager.useCache(CacheHandler); + + this.registerSchema(buildSchema(this)); + } + + createCache(capabilities: CacheCapabilitiesManager): Cache { + return new JSONAPICache(capabilities); + } + + instantiateRecord(identifier: StableRecordIdentifier, createRecordArgs: { [key: string]: unknown }): unknown { + return instantiateRecord.call(this, identifier, createRecordArgs); + } + + teardownRecord(record: Model): void { + return teardownRecord.call(this, record); + } + + modelFor(type: string) { + return modelFor.call(this, type); + } +} + +class User extends Model { + @attr declare name: string; +} + +module('Integration - createRecord', function (hooks) { + setupTest(hooks); + + hooks.beforeEach(function () { + setBuildURLConfig({ host: 'https://api.example.com', namespace: 'api/v1' }); + }); + + hooks.afterEach(function () { + setBuildURLConfig({ host: '', namespace: '' }); + }); + + test('Saving a new record with a createRecord op works as expected', async function (assert) { + const { owner } = this; + + // intercept cache APIs to ensure they are called as expected + class TestCache extends JSONAPICache { + willCommit(identifier: StableRecordIdentifier): void { + assert.step(`willCommit ${identifier.lid}`); + return super.willCommit(identifier); + } + didCommit( + committedIdentifier: StableRecordIdentifier, + result: StructuredDataDocument + ): SingleResourceDataDocument { + assert.step(`didCommit ${committedIdentifier.lid}`); + return super.didCommit(committedIdentifier, result); + } + commitWasRejected(identifier: StableRecordIdentifier, errors?: JsonApiError[]): void { + assert.step(`commitWasRejected ${identifier.lid}`); + return super.commitWasRejected(identifier, errors); + } + } + + // intercept Handler APIs to ensure they are called as expected + let response: unknown; + const TestHandler: Handler = { + request(context: RequestContext): Promise | Future { + assert.step(`handle ${context.request.op} request`); + assert.ok(response, 'response is set'); + + if (response instanceof Error) { + throw response; + } + return Promise.resolve(response as T); + }, + }; + + class Store extends TestStore { + constructor(args: unknown) { + super(args); + const manager = this.requestManager; + manager.use([TestHandler]); + } + createCache(capabilities: CacheCapabilitiesManager): Cache { + return new TestCache(capabilities); + } + } + + owner.register('service:store', Store); + owner.register('model:user', User); + const store = owner.lookup('service:store') as Store; + const user = store.createRecord('user', { name: 'John' }) as User; + const identifier = recordIdentifierFor(user); + assert.false(user.isSaving, 'The user is not saving'); + assert.true(user.isNew, 'The user is new'); + assert.true(user.hasDirtyAttributes, 'The user is dirty'); + + response = { + data: { + id: '1', + type: 'user', + attributes: { + name: 'Chris', + }, + }, + }; + + const promise = store.request(createRecord(user)); + assert.true(user.isSaving, 'The user is saving'); + + await promise; + + assert.strictEqual(user.id, '1', 'The user is updated from the response'); + assert.strictEqual(user.name, 'Chris', 'The user is updated from the response'); + assert.false(user.hasDirtyAttributes, 'The user is no longer dirty'); + assert.false(user.isNew, 'The user is no longer new'); + assert.false(user.isSaving, 'The user is no longer saving'); + + assert.verifySteps([`willCommit ${identifier.lid}`, 'handle createRecord request', `didCommit ${identifier.lid}`]); + }); + + test('Rejecting during Save of a new record with a createRecord op works as expected', async function (assert) { + const { owner } = this; + + // intercept cache APIs to ensure they are called as expected + class TestCache extends JSONAPICache { + willCommit(identifier: StableRecordIdentifier): void { + assert.step(`willCommit ${identifier.lid}`); + return super.willCommit(identifier); + } + didCommit( + committedIdentifier: StableRecordIdentifier, + result: StructuredDataDocument + ): SingleResourceDataDocument { + assert.step(`didCommit ${committedIdentifier.lid}`); + return super.didCommit(committedIdentifier, result); + } + commitWasRejected(identifier: StableRecordIdentifier, errors?: JsonApiError[]): void { + assert.step(`commitWasRejected ${identifier.lid}`); + return super.commitWasRejected(identifier, errors); + } + } + + // intercept Handler APIs to ensure they are called as expected + let response: unknown; + const TestHandler: Handler = { + request(context: RequestContext): Promise | Future { + assert.step(`handle ${context.request.op} request`); + assert.ok(response, 'response is set'); + + if (response instanceof Error) { + throw response; + } + return Promise.resolve(response as T); + }, + }; + + class Store extends TestStore { + constructor(args: unknown) { + super(args); + const manager = this.requestManager; + manager.use([TestHandler]); + } + createCache(capabilities: CacheCapabilitiesManager): Cache { + return new TestCache(capabilities); + } + } + + owner.register('service:store', Store); + owner.register('model:user', User); + const store = owner.lookup('service:store') as Store; + const user = store.createRecord('user', { name: 'John' }) as User; + const identifier = recordIdentifierFor(user); + assert.false(user.isSaving, 'The user is not saving'); + assert.true(user.isNew, 'The user is new'); + assert.true(user.hasDirtyAttributes, 'The user is dirty'); + + const validationError: Error & { + content: { errors: JsonApiError[] }; + } = new Error('Something went wrong') as Error & { + content: { errors: JsonApiError[] }; + }; + validationError.content = { + errors: [ + { + title: 'Name must be capitalized', + detail: 'Name must be capitalized', + source: { + pointer: '/data/attributes/name', + }, + }, + ], + }; + + response = validationError; + + const promise = store.request(createRecord(user)); + assert.true(user.isSaving, 'The user is saving'); + + try { + await promise; + assert.ok(false, 'The promise should reject'); + } catch (e: unknown) { + assert.true(e instanceof Error, 'The error is an error'); + assert.strictEqual((e as Error).message, 'Something went wrong', 'The error has the expected error message'); + assert.true( + Array.isArray((e as { content: { errors: JsonApiError[] } })?.content?.errors), + 'The error has an errors array' + ); + } + + assert.strictEqual(user.id, null, 'The user is not updated from the response'); + assert.strictEqual(user.name, 'John', 'The user is not updated from the response'); + assert.true(user.hasDirtyAttributes, 'The user is still dirty'); + assert.true(user.isNew, 'The user is still new'); + assert.false(user.isDeleted, 'The user is not deleted'); + assert.false(user.isDestroying, 'The user is not destroying'); + assert.false(user.isDestroyed, 'The user is not destroyed'); + assert.false(user.isSaving, 'The user is no longer saving'); + + const nameErrors = user.errors.get('name') as Array<{ + attribute: string; + message: string; + }>; + + assert.strictEqual(nameErrors.length, 1, 'The user has the expected number of errors'); + assert.strictEqual( + nameErrors[0]?.message, + 'Name must be capitalized', + 'The user has the expected error for the field' + ); + + assert.verifySteps([ + `willCommit ${identifier.lid}`, + 'handle createRecord request', + `commitWasRejected ${identifier.lid}`, + ]); + }); +}); diff --git a/tests/builders/tests/integration/delete-record-test.ts b/tests/builders/tests/integration/delete-record-test.ts new file mode 100644 index 00000000000..6d7759fe5fb --- /dev/null +++ b/tests/builders/tests/integration/delete-record-test.ts @@ -0,0 +1,285 @@ +import { module, test } from 'qunit'; + +import { setupTest } from 'ember-qunit'; + +import JSONAPICache from '@ember-data/json-api'; +import { deleteRecord } from '@ember-data/json-api/request'; +import Model, { attr, instantiateRecord, teardownRecord } from '@ember-data/model'; +import { buildSchema, modelFor } from '@ember-data/model/hooks'; +import RequestManager from '@ember-data/request'; +import type { Future, Handler, RequestContext } from '@ember-data/request/-private/types'; +import { setBuildURLConfig } from '@ember-data/request-utils'; +import DataStore, { CacheHandler, recordIdentifierFor } from '@ember-data/store'; +import type { Cache } from '@ember-data/types/cache/cache'; +import { SingleResourceDataDocument, StructuredDataDocument } from '@ember-data/types/cache/document'; +import type { CacheCapabilitiesManager } from '@ember-data/types/q/cache-store-wrapper'; +import { SingleResourceDocument } from '@ember-data/types/q/ember-data-json-api'; +import type { StableExistingRecordIdentifier, StableRecordIdentifier } from '@ember-data/types/q/identifier'; +import { JsonApiError } from '@ember-data/types/q/record-data-json-api'; + +class TestStore extends DataStore { + constructor(args: unknown) { + super(args); + + const manager = (this.requestManager = new RequestManager()); + manager.useCache(CacheHandler); + + this.registerSchema(buildSchema(this)); + } + + createCache(capabilities: CacheCapabilitiesManager): Cache { + return new JSONAPICache(capabilities); + } + + instantiateRecord(identifier: StableRecordIdentifier, createRecordArgs: { [key: string]: unknown }): unknown { + return instantiateRecord.call(this, identifier, createRecordArgs); + } + + teardownRecord(record: Model): void { + return teardownRecord.call(this, record); + } + + modelFor(type: string) { + return modelFor.call(this, type); + } +} + +class User extends Model { + @attr declare name: string; +} + +module('Integration - deleteRecord', function (hooks) { + setupTest(hooks); + + hooks.beforeEach(function () { + setBuildURLConfig({ host: 'https://api.example.com', namespace: 'api/v1' }); + }); + + hooks.afterEach(function () { + setBuildURLConfig({ host: '', namespace: '' }); + }); + + test('Persisting deletion for a record with a deleteRecord op works as expected', async function (assert) { + const { owner } = this; + + // intercept cache APIs to ensure they are called as expected + class TestCache extends JSONAPICache { + willCommit(identifier: StableRecordIdentifier): void { + assert.step(`willCommit ${identifier.lid}`); + return super.willCommit(identifier); + } + didCommit( + committedIdentifier: StableRecordIdentifier, + result: StructuredDataDocument + ): SingleResourceDataDocument { + assert.step(`didCommit ${committedIdentifier.lid}`); + return super.didCommit(committedIdentifier, result); + } + commitWasRejected(identifier: StableRecordIdentifier, errors?: JsonApiError[]): void { + assert.step(`commitWasRejected ${identifier.lid}`); + return super.commitWasRejected(identifier, errors); + } + } + + // intercept Handler APIs to ensure they are called as expected + let response: unknown; + const TestHandler: Handler = { + request(context: RequestContext): Promise | Future { + assert.step(`handle ${context.request.op} request`); + assert.ok(response, 'response is set'); + + if (response instanceof Error) { + throw response; + } + return Promise.resolve(response as T); + }, + }; + + class Store extends TestStore { + constructor(args: unknown) { + super(args); + const manager = this.requestManager; + manager.use([TestHandler]); + } + createCache(capabilities: CacheCapabilitiesManager): Cache { + return new TestCache(capabilities); + } + } + + owner.register('service:store', Store); + owner.register('model:user', User); + const store = owner.lookup('service:store') as Store; + const user = store.push({ data: { type: 'user', id: '1', attributes: { name: 'Chris' } } }) as User; + const identifier = recordIdentifierFor(user); + assert.false(user.isSaving, 'The user is not saving'); + assert.false(user.isDeleted, 'The user is not deleted'); + assert.false(user.hasDirtyAttributes, 'The user is not dirty'); + + // our delete response will include some sideloaded data + // to ensure it is properly handled + response = { + included: [ + { + id: '2', + type: 'user', + attributes: { + name: 'John', + }, + }, + ], + }; + + store.deleteRecord(user); + + assert.true(user.isDeleted, 'The user is deleted'); + assert.false(user.isSaving, 'The user is not saving'); + assert.true(user.hasDirtyAttributes, 'The user is dirty'); + assert.strictEqual(user.currentState.stateName, 'root.deleted.uncommitted', 'The user is in the correct state'); + assert.strictEqual(user.dirtyType, 'deleted', 'The user is dirty with the correct type'); + + const promise = store.request(deleteRecord(user)); + assert.true(user.isSaving, 'The user is saving'); + + await promise; + + assert.false(user.hasDirtyAttributes, 'The user is not dirty'); + assert.strictEqual(user.currentState.stateName, 'root.deleted.saved', 'The user is in the correct state'); + assert.strictEqual(user.dirtyType, '', 'The user is no longer dirty'); + assert.true(user.isDeleted, 'The user is deleted'); + assert.false(user.isSaving, 'The user is no longer saving'); + + assert.verifySteps([`willCommit ${identifier.lid}`, 'handle deleteRecord request', `didCommit ${identifier.lid}`]); + + const user2 = store.peekRecord('user', '2') as User; + assert.notStrictEqual(user2, null, 'The user is in the store'); + assert.strictEqual(user2?.name, 'John', 'The user has the expected name'); + }); + + test('Rejecting while persisting a deletion with a deleteRecord op works as expected', async function (assert) { + const { owner } = this; + + // intercept cache APIs to ensure they are called as expected + class TestCache extends JSONAPICache { + willCommit(identifier: StableRecordIdentifier): void { + assert.step(`willCommit ${identifier.lid}`); + return super.willCommit(identifier); + } + didCommit( + committedIdentifier: StableRecordIdentifier, + result: StructuredDataDocument + ): SingleResourceDataDocument { + assert.step(`didCommit ${committedIdentifier.lid}`); + return super.didCommit(committedIdentifier, result); + } + commitWasRejected(identifier: StableRecordIdentifier, errors?: JsonApiError[]): void { + assert.step(`commitWasRejected ${identifier.lid}`); + return super.commitWasRejected(identifier, errors); + } + } + + // intercept Handler APIs to ensure they are called as expected + let response: unknown; + const TestHandler: Handler = { + request(context: RequestContext): Promise | Future { + assert.step(`handle ${context.request.op} request`); + assert.ok(response, 'response is set'); + + if (response instanceof Error) { + throw response; + } + return Promise.resolve(response as T); + }, + }; + + class Store extends TestStore { + constructor(args: unknown) { + super(args); + const manager = this.requestManager; + manager.use([TestHandler]); + } + createCache(capabilities: CacheCapabilitiesManager): Cache { + return new TestCache(capabilities); + } + } + + owner.register('service:store', Store); + owner.register('model:user', User); + const store = owner.lookup('service:store') as Store; + const user = store.push({ data: { type: 'user', id: '1', attributes: { name: 'Chris' } } }) as User; + const identifier = recordIdentifierFor(user); + assert.false(user.isSaving, 'The user is not saving'); + assert.false(user.isDeleted, 'The user is not deleted'); + assert.false(user.hasDirtyAttributes, 'The user is not dirty'); + + // our delete response will include some sideloaded data + // to ensure it is properly handled + response = { + included: [ + { + id: '2', + type: 'user', + attributes: { + name: 'John', + }, + }, + ], + }; + + store.deleteRecord(user); + + assert.true(user.isDeleted, 'The user is deleted'); + assert.false(user.isSaving, 'The user is not saving'); + assert.true(user.hasDirtyAttributes, 'The user is dirty'); + assert.strictEqual(user.currentState.stateName, 'root.deleted.uncommitted', 'The user is in the correct state'); + assert.strictEqual(user.dirtyType, 'deleted', 'The user is dirty with the correct type'); + + const validationError: Error & { + content: { errors: JsonApiError[] }; + } = new Error('405 | Not Authorized') as Error & { + content: { errors: JsonApiError[] }; + }; + validationError.content = { + errors: [ + { + title: 'Not Authorized', + detail: 'Not Authorized', + source: { + pointer: '/data', + }, + }, + ], + }; + + response = validationError; + + const promise = store.request(deleteRecord(user)); + assert.true(user.isSaving, 'The user is saving'); + + try { + await promise; + assert.ok(false, 'The promise should reject'); + } catch (e: unknown) { + assert.true(e instanceof Error, 'The error is an error'); + assert.strictEqual((e as Error).message, '405 | Not Authorized', 'The error has the expected error message'); + assert.true( + Array.isArray((e as { content: { errors: JsonApiError[] } })?.content?.errors), + 'The error has an errors array' + ); + } + + assert.false(user.isDestroying, 'The user is not destroying'); + assert.false(user.isDestroyed, 'The user is not destroyed'); + assert.true(user.hasDirtyAttributes, 'The user is still dirty'); + assert.strictEqual(user.currentState.stateName, 'root.deleted.invalid', 'The user is in the correct state'); + assert.strictEqual(user.dirtyType, 'deleted', 'The user is still dirty'); + assert.strictEqual(user.adapterError?.message, '405 | Not Authorized', 'The user has the expected error message'); + assert.true(user.isDeleted, 'The user is still deleted'); + assert.false(user.isSaving, 'The user is no longer saving'); + + assert.verifySteps([ + `willCommit ${identifier.lid}`, + 'handle deleteRecord request', + `commitWasRejected ${identifier.lid}`, + ]); + }); +}); diff --git a/tests/builders/tests/integration/update-record-test.ts b/tests/builders/tests/integration/update-record-test.ts new file mode 100644 index 00000000000..d276aabcc93 --- /dev/null +++ b/tests/builders/tests/integration/update-record-test.ts @@ -0,0 +1,261 @@ +import { module, test } from 'qunit'; + +import { setupTest } from 'ember-qunit'; + +import JSONAPICache from '@ember-data/json-api'; +import { updateRecord } from '@ember-data/json-api/request'; +import Model, { attr, instantiateRecord, teardownRecord } from '@ember-data/model'; +import { buildSchema, modelFor } from '@ember-data/model/hooks'; +import RequestManager from '@ember-data/request'; +import type { Future, Handler, RequestContext } from '@ember-data/request/-private/types'; +import { setBuildURLConfig } from '@ember-data/request-utils'; +import DataStore, { CacheHandler, recordIdentifierFor } from '@ember-data/store'; +import type { Cache } from '@ember-data/types/cache/cache'; +import { SingleResourceDataDocument, StructuredDataDocument } from '@ember-data/types/cache/document'; +import type { CacheCapabilitiesManager } from '@ember-data/types/q/cache-store-wrapper'; +import { SingleResourceDocument } from '@ember-data/types/q/ember-data-json-api'; +import type { StableExistingRecordIdentifier, StableRecordIdentifier } from '@ember-data/types/q/identifier'; +import { JsonApiError } from '@ember-data/types/q/record-data-json-api'; + +class TestStore extends DataStore { + constructor(args: unknown) { + super(args); + + const manager = (this.requestManager = new RequestManager()); + manager.useCache(CacheHandler); + + this.registerSchema(buildSchema(this)); + } + + createCache(capabilities: CacheCapabilitiesManager): Cache { + return new JSONAPICache(capabilities); + } + + instantiateRecord(identifier: StableRecordIdentifier, createRecordArgs: { [key: string]: unknown }): unknown { + return instantiateRecord.call(this, identifier, createRecordArgs); + } + + teardownRecord(record: Model): void { + return teardownRecord.call(this, record); + } + + modelFor(type: string) { + return modelFor.call(this, type); + } +} + +class User extends Model { + @attr declare name: string; +} + +module('Integration - updateRecord', function (hooks) { + setupTest(hooks); + + hooks.beforeEach(function () { + setBuildURLConfig({ host: 'https://api.example.com', namespace: 'api/v1' }); + }); + + hooks.afterEach(function () { + setBuildURLConfig({ host: '', namespace: '' }); + }); + + test('Saving a record with an updateRecord op works as expected', async function (assert) { + const { owner } = this; + + // intercept cache APIs to ensure they are called as expected + class TestCache extends JSONAPICache { + willCommit(identifier: StableRecordIdentifier): void { + assert.step(`willCommit ${identifier.lid}`); + return super.willCommit(identifier); + } + didCommit( + committedIdentifier: StableRecordIdentifier, + result: StructuredDataDocument + ): SingleResourceDataDocument { + assert.step(`didCommit ${committedIdentifier.lid}`); + return super.didCommit(committedIdentifier, result); + } + commitWasRejected(identifier: StableRecordIdentifier, errors?: JsonApiError[]): void { + assert.step(`commitWasRejected ${identifier.lid}`); + return super.commitWasRejected(identifier, errors); + } + } + + // intercept Handler APIs to ensure they are called as expected + let response: unknown; + const TestHandler: Handler = { + request(context: RequestContext): Promise | Future { + assert.step(`handle ${context.request.op} request`); + assert.ok(response, 'response is set'); + + if (response instanceof Error) { + throw response; + } + return Promise.resolve(response as T); + }, + }; + + class Store extends TestStore { + constructor(args: unknown) { + super(args); + const manager = this.requestManager; + manager.use([TestHandler]); + } + createCache(capabilities: CacheCapabilitiesManager): Cache { + return new TestCache(capabilities); + } + } + + owner.register('service:store', Store); + owner.register('model:user', User); + const store = owner.lookup('service:store') as Store; + const user = store.push({ data: { type: 'user', id: '1', attributes: { name: 'Chris' } } }) as User; + const identifier = recordIdentifierFor(user); + assert.false(user.isSaving, 'The user is not saving'); + assert.false(user.isNew, 'The user is not new'); + assert.false(user.hasDirtyAttributes, 'The user is not dirty'); + + response = { + data: { + id: '1', + type: 'user', + attributes: { + name: 'James Thoburn', + }, + }, + }; + + user.name = 'James'; + + assert.true(user.hasDirtyAttributes, 'The user is dirty'); + const promise = store.request(updateRecord(user)); + assert.true(user.isSaving, 'The user is saving'); + + await promise; + + assert.strictEqual(user.name, 'James Thoburn', 'The user is updated from the response'); + assert.false(user.hasDirtyAttributes, 'The user is no longer dirty'); + assert.false(user.isSaving, 'The user is no longer saving'); + + assert.verifySteps([`willCommit ${identifier.lid}`, 'handle updateRecord request', `didCommit ${identifier.lid}`]); + }); + + test('Rejecting during Save of a new record with a createRecord op works as expected', async function (assert) { + const { owner } = this; + + // intercept cache APIs to ensure they are called as expected + class TestCache extends JSONAPICache { + willCommit(identifier: StableRecordIdentifier): void { + assert.step(`willCommit ${identifier.lid}`); + return super.willCommit(identifier); + } + didCommit( + committedIdentifier: StableRecordIdentifier, + result: StructuredDataDocument + ): SingleResourceDataDocument { + assert.step(`didCommit ${committedIdentifier.lid}`); + return super.didCommit(committedIdentifier, result); + } + commitWasRejected(identifier: StableRecordIdentifier, errors?: JsonApiError[]): void { + assert.step(`commitWasRejected ${identifier.lid}`); + return super.commitWasRejected(identifier, errors); + } + } + + // intercept Handler APIs to ensure they are called as expected + let response: unknown; + const TestHandler: Handler = { + request(context: RequestContext): Promise | Future { + assert.step(`handle ${context.request.op} request`); + assert.ok(response, 'response is set'); + + if (response instanceof Error) { + throw response; + } + return Promise.resolve(response as T); + }, + }; + + class Store extends TestStore { + constructor(args: unknown) { + super(args); + const manager = this.requestManager; + manager.use([TestHandler]); + } + createCache(capabilities: CacheCapabilitiesManager): Cache { + return new TestCache(capabilities); + } + } + + owner.register('service:store', Store); + owner.register('model:user', User); + const store = owner.lookup('service:store') as Store; + const user = store.push({ data: { type: 'user', id: '1', attributes: { name: 'Chris' } } }) as User; + const identifier = recordIdentifierFor(user); + assert.false(user.isSaving, 'The user is not saving'); + assert.false(user.isNew, 'The user is not new'); + assert.false(user.hasDirtyAttributes, 'The user is not dirty'); + + const validationError: Error & { + content: { errors: JsonApiError[] }; + } = new Error('Something went wrong') as Error & { + content: { errors: JsonApiError[] }; + }; + validationError.content = { + errors: [ + { + title: 'Name must be capitalized', + detail: 'Name must be capitalized', + source: { + pointer: '/data/attributes/name', + }, + }, + ], + }; + + response = validationError; + + user.name = 'james'; + assert.true(user.hasDirtyAttributes, 'The user is dirty'); + + const promise = store.request(updateRecord(user)); + assert.true(user.isSaving, 'The user is saving'); + + try { + await promise; + assert.ok(false, 'The promise should reject'); + } catch (e: unknown) { + assert.true(e instanceof Error, 'The error is an error'); + assert.strictEqual((e as Error).message, 'Something went wrong', 'The error has the expected error message'); + assert.true( + Array.isArray((e as { content: { errors: JsonApiError[] } })?.content?.errors), + 'The error has an errors array' + ); + } + + assert.true(user.hasDirtyAttributes, 'The user is still dirty'); + assert.false(user.isNew, 'The user is not new'); + assert.false(user.isDeleted, 'The user is not deleted'); + assert.false(user.isDestroying, 'The user is not destroying'); + assert.false(user.isDestroyed, 'The user is not destroyed'); + assert.false(user.isSaving, 'The user is no longer saving'); + + const nameErrors = user.errors.get('name') as Array<{ + attribute: string; + message: string; + }>; + + assert.strictEqual(nameErrors.length, 1, 'The user has the expected number of errors'); + assert.strictEqual( + nameErrors[0]?.message, + 'Name must be capitalized', + 'The user has the expected error for the field' + ); + + assert.verifySteps([ + `willCommit ${identifier.lid}`, + 'handle updateRecord request', + `commitWasRejected ${identifier.lid}`, + ]); + }); +}); diff --git a/tests/builders/tests/test-helper.js b/tests/builders/tests/test-helper.js index fee5be27fc0..bf70c2fce19 100644 --- a/tests/builders/tests/test-helper.js +++ b/tests/builders/tests/test-helper.js @@ -1,3 +1,5 @@ +import { setApplication } from '@ember/test-helpers'; + import * as QUnit from 'qunit'; import { start } from 'ember-qunit'; @@ -6,6 +8,9 @@ import assertAllDeprecations from '@ember-data/unpublished-test-infra/test-suppo import configureAsserts from '@ember-data/unpublished-test-infra/test-support/qunit-asserts'; import customQUnitAdapter from '@ember-data/unpublished-test-infra/test-support/testem/custom-qunit-adapter'; +import Application from '../app'; +import config from '../config/environment'; + // Handle testing feature flags if (QUnit.urlParams.enableoptionalfeatures) { window.EmberDataENV = { ENABLE_OPTIONAL_FEATURES: true }; @@ -13,6 +18,8 @@ if (QUnit.urlParams.enableoptionalfeatures) { configureAsserts(); +setApplication(Application.create(config.APP)); + assertAllDeprecations(); if (window.Testem) { diff --git a/tests/builders/tests/unit/active-record-builder-test.ts b/tests/builders/tests/unit/active-record-builder-test.ts index 5bc7ed9a1d5..ec7e19c3545 100644 --- a/tests/builders/tests/unit/active-record-builder-test.ts +++ b/tests/builders/tests/unit/active-record-builder-test.ts @@ -1,13 +1,19 @@ import { module, test } from 'qunit'; -import { findRecord, query } from '@ember-data/active-record/request'; +import { setupTest } from 'ember-qunit'; + +import { createRecord, deleteRecord, findRecord, query, updateRecord } from '@ember-data/active-record/request'; import { setBuildURLConfig } from '@ember-data/request-utils'; +import Store, { recordIdentifierFor } from '@ember-data/store'; +import UserSetting from '../../app/models/user-setting'; import { headersToObject } from '../helpers/utils'; const ACTIVE_RECORD_HEADERS = { 'content-type': 'application/json; charset=utf-8' }; module('ActiveRecord | Request Builders', function (hooks) { + setupTest(hooks); + hooks.beforeEach(function () { setBuildURLConfig({ host: 'https://api.example.com', namespace: 'api/v1' }); }); @@ -109,4 +115,161 @@ module('ActiveRecord | Request Builders', function (hooks) { ); assert.deepEqual(headersToObject(result.headers), ACTIVE_RECORD_HEADERS); }); + + test('createRecord passing store record', function (assert) { + const store = this.owner.lookup('service:store') as Store; + const userSetting = store.createRecord('user-setting', { + name: 'test', + }); + const identifier = recordIdentifierFor(userSetting); + const result = createRecord(userSetting); + + assert.deepEqual( + result, + { + url: 'https://api.example.com/api/v1/user_settings', + method: 'POST', + headers: new Headers(ACTIVE_RECORD_HEADERS), + op: 'createRecord', + data: { + record: identifier, + }, + }, + `createRecord works with record identifier passed` + ); + assert.deepEqual(headersToObject(result.headers), ACTIVE_RECORD_HEADERS, "headers are set to ActiveRecord API's"); + }); + + test('createRecord passing store record and options', function (assert) { + const store = this.owner.lookup('service:store') as Store; + const userSetting = store.createRecord('user-setting', { + name: 'test', + }); + const identifier = recordIdentifierFor(userSetting); + const result = createRecord(userSetting, { resourcePath: 'user-settings/new' }); + + assert.deepEqual( + result, + { + url: 'https://api.example.com/api/v1/user-settings/new', + method: 'POST', + headers: new Headers(ACTIVE_RECORD_HEADERS), + op: 'createRecord', + data: { + record: identifier, + }, + }, + `createRecord works with record identifier passed` + ); + assert.deepEqual(headersToObject(result.headers), ACTIVE_RECORD_HEADERS, "headers are set to ActiveRecord API's"); + }); + + test('updateRecord passing store record', function (assert) { + const store = this.owner.lookup('service:store') as Store; + + const expectedData = { + data: { + id: '12', + type: 'user-setting', + attributes: { + name: 'test', + }, + }, + }; + store.push(expectedData); + + const userSetting = store.peekRecord('user-setting', '12') as UserSetting; + const identifier = recordIdentifierFor(userSetting); + + userSetting.name = 'test2'; + + const result = updateRecord(userSetting); + + assert.deepEqual( + result, + { + url: 'https://api.example.com/api/v1/user_settings/12', + method: 'PUT', + headers: new Headers(ACTIVE_RECORD_HEADERS), + op: 'updateRecord', + data: { + record: identifier, + }, + }, + `updateRecord works with record identifier passed` + ); + assert.deepEqual(headersToObject(result.headers), ACTIVE_RECORD_HEADERS, "headers are set to ActiveRecord API's"); + }); + + test('updateRecord with PATCH method', function (assert) { + const store = this.owner.lookup('service:store') as Store; + + const expectedData = { + data: { + id: '12', + type: 'user-setting', + attributes: { + name: 'test', + }, + }, + }; + store.push(expectedData); + + const userSetting = store.peekRecord('user-setting', '12') as UserSetting; + const identifier = recordIdentifierFor(userSetting); + + userSetting.name = 'test2'; + + const result = updateRecord(userSetting, { patch: true }); + + assert.deepEqual( + result, + { + url: 'https://api.example.com/api/v1/user_settings/12', + method: 'PATCH', + headers: new Headers(ACTIVE_RECORD_HEADERS), + op: 'updateRecord', + data: { + record: identifier, + }, + }, + `updateRecord works with patch option` + ); + assert.deepEqual(headersToObject(result.headers), ACTIVE_RECORD_HEADERS, "headers are set to ActiveRecord API's"); + }); + + test('deleteRecord with identifier', function (assert) { + const store = this.owner.lookup('service:store') as Store; + + const expectedData = { + data: { + id: '12', + type: 'user-setting', + attributes: { + name: 'test', + }, + }, + }; + store.push(expectedData); + + const userSetting = store.peekRecord('user-setting', '12'); + const identifier = recordIdentifierFor(userSetting); + + const result = deleteRecord(userSetting); + + assert.deepEqual( + result, + { + url: 'https://api.example.com/api/v1/user_settings/12', + method: 'DELETE', + headers: new Headers(ACTIVE_RECORD_HEADERS), + op: 'deleteRecord', + data: { + record: identifier, + }, + }, + `deleteRecord works with patch option` + ); + assert.deepEqual(headersToObject(result.headers), ACTIVE_RECORD_HEADERS, "headers are set to ActiveRecord API's"); + }); }); diff --git a/tests/builders/tests/unit/json-api-builder-test.ts b/tests/builders/tests/unit/json-api-builder-test.ts index 024f4f81753..0b8a28d9c67 100644 --- a/tests/builders/tests/unit/json-api-builder-test.ts +++ b/tests/builders/tests/unit/json-api-builder-test.ts @@ -1,13 +1,19 @@ import { module, test } from 'qunit'; -import { findRecord, query } from '@ember-data/json-api/request'; +import { setupTest } from 'ember-qunit'; + +import { createRecord, deleteRecord, findRecord, query, updateRecord } from '@ember-data/json-api/request'; import { setBuildURLConfig } from '@ember-data/request-utils'; +import Store, { recordIdentifierFor } from '@ember-data/store'; +import UserSetting from '../../app/models/user-setting'; import { headersToObject } from '../helpers/utils'; const JSON_API_HEADERS = { accept: 'application/vnd.api+json', 'content-type': 'application/vnd.api+json' }; module('JSON:API | Request Builders', function (hooks) { + setupTest(hooks); + hooks.beforeEach(function () { setBuildURLConfig({ host: 'https://api.example.com', namespace: 'api/v1' }); }); @@ -109,4 +115,161 @@ module('JSON:API | Request Builders', function (hooks) { ); assert.deepEqual(headersToObject(result.headers), JSON_API_HEADERS); }); + + test('createRecord passing store record', function (assert) { + const store = this.owner.lookup('service:store') as Store; + const userSetting = store.createRecord('user-setting', { + name: 'test', + }); + const identifier = recordIdentifierFor(userSetting); + const result = createRecord(userSetting); + + assert.deepEqual( + result, + { + url: 'https://api.example.com/api/v1/user-settings', + method: 'POST', + headers: new Headers(JSON_API_HEADERS), + op: 'createRecord', + data: { + record: identifier, + }, + }, + `createRecord works with record identifier passed` + ); + assert.deepEqual(headersToObject(result.headers), JSON_API_HEADERS, "headers are set to JSON API's"); + }); + + test('createRecord passing store record and options', function (assert) { + const store = this.owner.lookup('service:store') as Store; + const userSetting = store.createRecord('user-setting', { + name: 'test', + }); + const identifier = recordIdentifierFor(userSetting); + const result = createRecord(userSetting, { resourcePath: 'user-settings/new' }); + + assert.deepEqual( + result, + { + url: 'https://api.example.com/api/v1/user-settings/new', + method: 'POST', + headers: new Headers(JSON_API_HEADERS), + op: 'createRecord', + data: { + record: identifier, + }, + }, + `createRecord works with record identifier passed` + ); + assert.deepEqual(headersToObject(result.headers), JSON_API_HEADERS, "headers are set to JSON API's"); + }); + + test('updateRecord passing store record', function (assert) { + const store = this.owner.lookup('service:store') as Store; + + const expectedData = { + data: { + id: '12', + type: 'user-setting', + attributes: { + name: 'test', + }, + }, + }; + store.push(expectedData); + + const userSetting = store.peekRecord('user-setting', '12') as UserSetting; + const identifier = recordIdentifierFor(userSetting); + + userSetting.name = 'test2'; + + const result = updateRecord(userSetting); + + assert.deepEqual( + result, + { + url: 'https://api.example.com/api/v1/user-settings/12', + method: 'PUT', + headers: new Headers(JSON_API_HEADERS), + op: 'updateRecord', + data: { + record: identifier, + }, + }, + `updateRecord works with record identifier passed` + ); + assert.deepEqual(headersToObject(result.headers), JSON_API_HEADERS, "headers are set to JSON API's"); + }); + + test('updateRecord with PATCH method', function (assert) { + const store = this.owner.lookup('service:store') as Store; + + const expectedData = { + data: { + id: '12', + type: 'user-setting', + attributes: { + name: 'test', + }, + }, + }; + store.push(expectedData); + + const userSetting = store.peekRecord('user-setting', '12') as UserSetting; + const identifier = recordIdentifierFor(userSetting); + + userSetting.name = 'test2'; + + const result = updateRecord(userSetting, { patch: true }); + + assert.deepEqual( + result, + { + url: 'https://api.example.com/api/v1/user-settings/12', + method: 'PATCH', + headers: new Headers(JSON_API_HEADERS), + op: 'updateRecord', + data: { + record: identifier, + }, + }, + `updateRecord works with patch option` + ); + assert.deepEqual(headersToObject(result.headers), JSON_API_HEADERS, "headers are set to JSON API's"); + }); + + test('deleteRecord with identifier', function (assert) { + const store = this.owner.lookup('service:store') as Store; + + const expectedData = { + data: { + id: '12', + type: 'user-setting', + attributes: { + name: 'test', + }, + }, + }; + store.push(expectedData); + + const userSetting = store.peekRecord('user-setting', '12'); + const identifier = recordIdentifierFor(userSetting); + + const result = deleteRecord(userSetting); + + assert.deepEqual( + result, + { + url: 'https://api.example.com/api/v1/user-settings/12', + method: 'DELETE', + headers: new Headers(JSON_API_HEADERS), + op: 'deleteRecord', + data: { + record: identifier, + }, + }, + `deleteRecord works with patch option` + ); + assert.deepEqual(headersToObject(result.headers), JSON_API_HEADERS, "headers are set to JSON API's"); + }); }); diff --git a/tests/builders/tests/unit/rest-builder-test.ts b/tests/builders/tests/unit/rest-builder-test.ts index 0ac2ccc9e7a..8c10128936b 100644 --- a/tests/builders/tests/unit/rest-builder-test.ts +++ b/tests/builders/tests/unit/rest-builder-test.ts @@ -1,13 +1,19 @@ import { module, test } from 'qunit'; +import { setupTest } from 'ember-qunit'; + import { setBuildURLConfig } from '@ember-data/request-utils'; -import { findRecord, query } from '@ember-data/rest/request'; +import { createRecord, deleteRecord, findRecord, query, updateRecord } from '@ember-data/rest/request'; +import Store, { recordIdentifierFor } from '@ember-data/store'; +import UserSetting from '../../app/models/user-setting'; import { headersToObject } from '../helpers/utils'; const REST_HEADERS = { 'content-type': 'application/json; charset=utf-8' }; module('REST | Request Builders', function (hooks) { + setupTest(hooks); + hooks.beforeEach(function () { setBuildURLConfig({ host: 'https://api.example.com', namespace: 'api/v1' }); }); @@ -109,4 +115,161 @@ module('REST | Request Builders', function (hooks) { ); assert.deepEqual(headersToObject(result.headers), REST_HEADERS); }); + + test('createRecord passing store record', function (assert) { + const store = this.owner.lookup('service:store') as Store; + const userSetting = store.createRecord('user-setting', { + name: 'test', + }); + const identifier = recordIdentifierFor(userSetting); + const result = createRecord(userSetting); + + assert.deepEqual( + result, + { + url: 'https://api.example.com/api/v1/userSettings', + method: 'POST', + headers: new Headers(REST_HEADERS), + op: 'createRecord', + data: { + record: identifier, + }, + }, + `createRecord works with record identifier passed` + ); + assert.deepEqual(headersToObject(result.headers), REST_HEADERS, "headers are set to REST API's"); + }); + + test('createRecord passing store record and options', function (assert) { + const store = this.owner.lookup('service:store') as Store; + const userSetting = store.createRecord('user-setting', { + name: 'test', + }); + const identifier = recordIdentifierFor(userSetting); + const result = createRecord(userSetting, { resourcePath: 'userSettings/new' }); + + assert.deepEqual( + result, + { + url: 'https://api.example.com/api/v1/userSettings/new', + method: 'POST', + headers: new Headers(REST_HEADERS), + op: 'createRecord', + data: { + record: identifier, + }, + }, + `createRecord works with record identifier passed` + ); + assert.deepEqual(headersToObject(result.headers), REST_HEADERS, "headers are set to REST API's"); + }); + + test('updateRecord passing store record', function (assert) { + const store = this.owner.lookup('service:store') as Store; + + const expectedData = { + data: { + id: '12', + type: 'user-setting', + attributes: { + name: 'test', + }, + }, + }; + store.push(expectedData); + + const userSetting = store.peekRecord('user-setting', '12') as UserSetting; + const identifier = recordIdentifierFor(userSetting); + + userSetting.name = 'test2'; + + const result = updateRecord(userSetting); + + assert.deepEqual( + result, + { + url: 'https://api.example.com/api/v1/userSettings/12', + method: 'PUT', + headers: new Headers(REST_HEADERS), + op: 'updateRecord', + data: { + record: identifier, + }, + }, + `updateRecord works with record identifier passed` + ); + assert.deepEqual(headersToObject(result.headers), REST_HEADERS, "headers are set to REST API's"); + }); + + test('updateRecord with PATCH method', function (assert) { + const store = this.owner.lookup('service:store') as Store; + + const expectedData = { + data: { + id: '12', + type: 'user-setting', + attributes: { + name: 'test', + }, + }, + }; + store.push(expectedData); + + const userSetting = store.peekRecord('user-setting', '12') as UserSetting; + const identifier = recordIdentifierFor(userSetting); + + userSetting.name = 'test2'; + + const result = updateRecord(userSetting, { patch: true }); + + assert.deepEqual( + result, + { + url: 'https://api.example.com/api/v1/userSettings/12', + method: 'PATCH', + headers: new Headers(REST_HEADERS), + op: 'updateRecord', + data: { + record: identifier, + }, + }, + `updateRecord works with patch option` + ); + assert.deepEqual(headersToObject(result.headers), REST_HEADERS, "headers are set to REST API's"); + }); + + test('deleteRecord with identifier', function (assert) { + const store = this.owner.lookup('service:store') as Store; + + const expectedData = { + data: { + id: '12', + type: 'user-setting', + attributes: { + name: 'test', + }, + }, + }; + store.push(expectedData); + + const userSetting = store.peekRecord('user-setting', '12'); + const identifier = recordIdentifierFor(userSetting); + + const result = deleteRecord(userSetting); + + assert.deepEqual( + result, + { + url: 'https://api.example.com/api/v1/userSettings/12', + method: 'DELETE', + headers: new Headers(REST_HEADERS), + op: 'deleteRecord', + data: { + record: identifier, + }, + }, + `deleteRecord works with patch option` + ); + assert.deepEqual(headersToObject(result.headers), REST_HEADERS, "headers are set to REST API's"); + }); }); diff --git a/tests/docs/fixtures/expected.js b/tests/docs/fixtures/expected.js index 5063c527297..4898b6cc795 100644 --- a/tests/docs/fixtures/expected.js +++ b/tests/docs/fixtures/expected.js @@ -104,6 +104,9 @@ module.exports = { '(private) @ember-data/store Store#init', '(public) @ember-data/active-record/request @ember-data/active-record/request#query', '(public) @ember-data/active-record/request @ember-data/active-record/request#findRecord', + '(public) @ember-data/active-record/request @ember-data/active-record/request#createRecord', + '(public) @ember-data/active-record/request @ember-data/active-record/request#deleteRecord', + '(public) @ember-data/active-record/request @ember-data/active-record/request#updateRecord', '(public) @ember-data/adapter Adapter#coalesceFindRequests', '(public) @ember-data/adapter Adapter#createRecord', '(public) @ember-data/adapter Adapter#deleteRecord', @@ -252,6 +255,9 @@ module.exports = { '(public) @ember-data/json-api Cache#rollbackRelationships', '(public) @ember-data/json-api/request @ember-data/json-api/request#findRecord', '(public) @ember-data/json-api/request @ember-data/json-api/request#query', + '(public) @ember-data/json-api/request @ember-data/json-api/request#createRecord', + '(public) @ember-data/json-api/request @ember-data/json-api/request#deleteRecord', + '(public) @ember-data/json-api/request @ember-data/json-api/request#updateRecord', '(public) @ember-data/json-api/request @ember-data/json-api/request#serializePatch', '(public) @ember-data/json-api/request @ember-data/json-api/request#serializeResources', '(public) @ember-data/legacy-compat SnapshotRecordArray#adapterOptions', @@ -356,6 +362,9 @@ module.exports = { '(public) @ember-data/request-utils @ember-data/request-utils#sortQueryParams', '(public) @ember-data/rest/request @ember-data/rest/request#findRecord', '(public) @ember-data/rest/request @ember-data/rest/request#query', + '(public) @ember-data/rest/request @ember-data/rest/request#createRecord', + '(public) @ember-data/rest/request @ember-data/rest/request#deleteRecord', + '(public) @ember-data/rest/request @ember-data/rest/request#updateRecord', '(public) @ember-data/serializer Serializer#normalize', '(public) @ember-data/serializer Serializer#normalizeResponse', '(public) @ember-data/serializer Serializer#serialize', diff --git a/tests/request/tests/integration/immutability-test.ts b/tests/request/tests/integration/immutability-test.ts index 4fb0ce4d5e5..293af328092 100644 --- a/tests/request/tests/integration/immutability-test.ts +++ b/tests/request/tests/integration/immutability-test.ts @@ -52,7 +52,7 @@ module('RequestManager | Immutability', function () { const manager = new RequestManager(); const handler: Handler = { request(context: Context, next: NextFn) { - const headers = context.request.headers!.clone(); + const headers = new Headers(context.request.headers); headers.append('house', 'home'); return Promise.resolve([...headers.entries()] as T); },