mirror of
https://github.com/strapi/strapi.git
synced 2026-01-08 13:17:47 +00:00
Merge branch 'v5/main' into v5/replace-axios
This commit is contained in:
commit
dfd5bbf8d6
144
docs/docs/guides/05-type-system/02-concepts/01-schema.mdx
Normal file
144
docs/docs/guides/05-type-system/02-concepts/01-schema.mdx
Normal file
@ -0,0 +1,144 @@
|
||||
---
|
||||
title: Schema
|
||||
tags:
|
||||
- typescript
|
||||
- type system
|
||||
- type
|
||||
- concepts
|
||||
---
|
||||
|
||||
The schema is the primary data structure leveraged within the Strapi Type System, defining how content is structured and managed in the application.
|
||||
|
||||
It serves several key functions:
|
||||
|
||||
- **Representation**: At its core, a schema outlines and defines the structure of Strapi content. This is useful when dealing with features that need access to low level schema properties (_e.g. attributes, plugin options, etc..._).
|
||||
|
||||
- **Inference**: The schema allows inferring and configuring numerous other types. This includes entities like `ContentType` or `Component`, among others.
|
||||
|
||||
### Scope
|
||||
|
||||
Schema types represent **loaded** schemas in the context of a Strapi server application and should be used accordingly.
|
||||
|
||||
:::caution
|
||||
Database models and raw schema definitions (_aka schemas before being loaded by the Strapi server_) are **not** the same types and can't be used interchangeably.
|
||||
:::
|
||||
|
||||
### Sub-Types
|
||||
|
||||
Each box is a type that extends the base Schema interface.
|
||||
|
||||
In between each box is text that represents the discriminant used to differentiate the subtype from others.
|
||||
|
||||
```mermaid
|
||||
flowchart TB;
|
||||
Schema -- "<code>modelType: contentType</code>" ---- ContentTypeSchema
|
||||
Schema -- "<code>modelType: component</code>" ---- ComponentSchema
|
||||
ContentTypeSchema -- "<code>kind: collectionType</code>" ---- CollectionTypeSchema
|
||||
ContentTypeSchema -- "<code>kind: singleType</code>" ---- SingleTypeSchema
|
||||
```
|
||||
|
||||
### Properties
|
||||
|
||||
Schema types contain useful information that helps other types know how to interact with the Strapi content.
|
||||
|
||||
This is facilitated through multiple properties.
|
||||
|
||||
#### Options
|
||||
|
||||
A set of properties used to configure the schema. It contains information on features activation among other things.
|
||||
|
||||
This can be really useful to make the types adapt to a given schema.
|
||||
|
||||
For instance, the document service uses the `options.draftAndPublish` property to determine whether it should add publication methods to the service type.
|
||||
|
||||
#### Plugin Options
|
||||
|
||||
These options provide the ability to alter or enhance the behaviour of the system based on specific values.
|
||||
|
||||
If a plugin is enabled, it might bring functionality that can affect how types interact with each other.
|
||||
|
||||
For example, it's possible to add or remove certain entity-service filters from the query type based on whether a plugin is enabled.
|
||||
|
||||
#### Attributes
|
||||
|
||||
Strongly typed schema attributes allows the Type System to infer actual entities types based on their properties.
|
||||
|
||||
For instance, a string attribute will resolve to a primitive string in an entity, whereas a repeatable component attribute will resolve to an array of objects.
|
||||
|
||||
### Usage
|
||||
|
||||
import Tabs from '@theme/Tabs';
|
||||
import TabItem from '@theme/TabItem';
|
||||
|
||||
<Tabs>
|
||||
<TabItem value="public" label="Public" default>
|
||||
When designing public APIs (and in most other scenarios), it's advised to use the high-level schema types found in the `Schema` namespace.
|
||||
|
||||
Schema definitions exported from the `Schema` namespace are targeting the dynamic types found in the public schema registries, and will dynamically adapt to the current context while extending the base Schema types.
|
||||
|
||||
:::info
|
||||
If the public registries are empty (_e.g. types are not generated yet, not in the context of a Strapi application, ..._), schema types will fallback to their low-level definitions.
|
||||
:::
|
||||
|
||||
```typescript
|
||||
import type { Schema } from '@strapi/strapi';
|
||||
|
||||
declare const schema: Schema.Schema;
|
||||
declare const contentType: Schema.ContentType;
|
||||
declare const component: Schema.Component;
|
||||
|
||||
declare function processAnySchema(schema: Schema.Schema): void;
|
||||
|
||||
processAnySchema(schema); // ✅
|
||||
processAnySchema(contentType); // ✅
|
||||
processAnySchema(component); // ✅
|
||||
|
||||
declare function processContentTypeSchema(schema: Schema.ContentType): void;
|
||||
|
||||
processContentTypeSchema(schema); // ✅
|
||||
processContentTypeSchema(contentType); // ✅
|
||||
processContentTypeSchema(component); // ❌ Error, a component schema is not assignable to a content-type schema
|
||||
|
||||
declare function processComponentSchema(schema: Schema.Component): void;
|
||||
|
||||
processComponentSchema(schema); // ✅
|
||||
processComponentSchema(contentType); // ❌ Error, a content-type schema is not assignable to a component schema
|
||||
processComponentSchema(component); // ✅
|
||||
```
|
||||
|
||||
</TabItem>
|
||||
<TabItem value="internal" label="Internal">
|
||||
Schema definitions exported from the `Struct` namespace defines the low level type representation of Strapi schemas.
|
||||
|
||||
:::caution
|
||||
Those types can be useful when you want to validate other types against the base ones, but realistically, the public Schema types should almost always be preferred.
|
||||
:::
|
||||
|
||||
```typescript
|
||||
import type { Struct } from '@strapi/strapi';
|
||||
|
||||
declare const schema: Struct.Schema;
|
||||
declare const contentType: Struct.ContentTypeSchema;
|
||||
declare const component: Struct.ComponentSchema;
|
||||
|
||||
declare function processAnySchema(schema: Struct.Schema): void;
|
||||
|
||||
processAnySchema(schema); // ✅
|
||||
processAnySchema(contentType); // ✅
|
||||
processAnySchema(component); // ✅
|
||||
|
||||
declare function processContentTypeSchema(schema: Struct.ContentTypeSchema): void;
|
||||
|
||||
processContentTypeSchema(schema); // ✅
|
||||
processContentTypeSchema(contentType); // ✅
|
||||
processContentTypeSchema(component); // ❌ Error, a component schema is not assignable to a content-type schema
|
||||
|
||||
declare function processComponentSchema(schema: Struct.ComponentSchema): void;
|
||||
|
||||
processComponentSchema(schema); // ✅
|
||||
processComponentSchema(contentType); // ❌ Error, a content-type schema is not assignable to a component schema
|
||||
processComponentSchema(component); // ✅
|
||||
```
|
||||
|
||||
</TabItem>
|
||||
</Tabs>
|
||||
190
docs/docs/guides/05-type-system/02-concepts/02-uid.mdx
Normal file
190
docs/docs/guides/05-type-system/02-concepts/02-uid.mdx
Normal file
@ -0,0 +1,190 @@
|
||||
---
|
||||
title: UID
|
||||
tags:
|
||||
- typescript
|
||||
- type system
|
||||
- type
|
||||
- concepts
|
||||
toc_max_heading_level: 4
|
||||
---
|
||||
|
||||
:::note
|
||||
On this page, **a resource** is considered as **anything that can be identified by a UID**.
|
||||
|
||||
This includes (but is not limited to) controllers, schema, services, policies, middlewares, etc...
|
||||
:::
|
||||
|
||||
In the Type System, UIDs play a crucial role in referencing various resources (such as schema and entities) by attaching a unique identifier.
|
||||
|
||||
To put it simply, a UID is a unique (string) literal key used to identify, locate, or access a particular resource within the system.
|
||||
|
||||
:::tip
|
||||
This makes it the perfect tool to index type registries or to use as a type parameter for resource-centric types.
|
||||
:::
|
||||
|
||||
### Format
|
||||
|
||||
A UID is composed of 3 different parts:
|
||||
|
||||
1. A namespace ([link](#1-namespaces))
|
||||
2. A separator ([link](#2-separators))
|
||||
3. A name ([link](#3-names))
|
||||
|
||||
#### 1. Namespaces
|
||||
|
||||
There are two main families of namespaces:
|
||||
|
||||
- Scoped (_aka parametrized_)
|
||||
- Non-scoped (_aka constants_)
|
||||
|
||||
A third kind exists for component UIDs and is defined only by a dynamic category: `<category>`.
|
||||
|
||||
##### Scoped
|
||||
|
||||
Scoped namespaces are defined by a base name, followed by a separator (`::`) and any string.
|
||||
|
||||
In Strapi there are two of them:
|
||||
|
||||
| Name | Definition | Description |
|
||||
| ------ | :---------------: | ---------------------------------------------------- |
|
||||
| API | `api::<scope>` | Represent a resource present in the `<scope>` API |
|
||||
| Plugin | `plugin::<scope>` | Represent a resource present in the `<scope>` plugin |
|
||||
|
||||
##### Non-Scoped
|
||||
|
||||
These namespaces are used as a simple prefix and define the origin of a resource.
|
||||
|
||||
Strapi uses three of them to create UIDs
|
||||
|
||||
| Name | Definition | Description |
|
||||
| ------ | :--------: | ----------------------------------------------------------------------------- |
|
||||
| Strapi | `strapi` | Represent a resource present in the core of strapi |
|
||||
| Admin | `admin` | Represent a resource present in Strapi admin |
|
||||
| Global | `global` | Rarely used (_e.g. policies or middlewares_), it represents a global resource |
|
||||
|
||||
#### 2. Separators
|
||||
|
||||
There are only two kind of separators:
|
||||
|
||||
- `.` for scoped namespaces (`api::<scope>`, `plugin::<scope>`) and components (`<category>`)
|
||||
- `::` for others (`admin`, `strapi`, `global`)
|
||||
|
||||
#### 3. Names
|
||||
|
||||
UID names can be any alphanumeric string.
|
||||
|
||||
:::caution
|
||||
A UID is unique for the kind of resource it's attached to, but **different resource can share the same UID**.
|
||||
|
||||
For instance, it's completely possible to have both a `service` and a `schema` identified by `api::article.article`.
|
||||
|
||||
Since **TypeScript is a structural type system**, it means that **different UIDs resolving to the same literal type can match each other**, thus making it possible to send a service UID to a method expecting a schema UID (if they share the same format).
|
||||
:::
|
||||
|
||||
### Compatibility Table
|
||||
|
||||
The following table shows, for each kind of UID, what resource they can be associated with.
|
||||
|
||||
:::note
|
||||
ContentType and Component are referring to both the related schema and entity.
|
||||
:::
|
||||
|
||||
| | ContentType | Component | Middleware | Policy | Controller | Service |
|
||||
| ------------------------ | :----------------: | :----------------: | :----------------: | :----------------: | :----------------: | :----------------: |
|
||||
| `api::<scope>.<name>` | :white_check_mark: | :x: | :white_check_mark: | :white_check_mark: | :white_check_mark: | :white_check_mark: |
|
||||
| `plugin::<scope>.<name>` | :white_check_mark: | :x: | :white_check_mark: | :white_check_mark: | :white_check_mark: | :white_check_mark: |
|
||||
| `<category>.<name>` | :x: | :white_check_mark: | :x: | :x: | :x: | :x: |
|
||||
| `strapi::<name>` | :white_check_mark: | :x: | :white_check_mark: | :white_check_mark: | :x: | :x: |
|
||||
| `admin::<name>` | :white_check_mark: | :x: | :white_check_mark: | :white_check_mark: | :white_check_mark: | :white_check_mark: |
|
||||
| `global::<name>` | :x: | :x: | :white_check_mark: | :white_check_mark: | :x: | :x: |
|
||||
|
||||
### Usage
|
||||
|
||||
When referencing resource by their UID you'll need to use the `UID` namespace exported from `@strapi/types`.
|
||||
|
||||
```typescript
|
||||
import type { UID } from '@strapi/types';
|
||||
```
|
||||
|
||||
This namespace contains shortcuts to dynamic UID types built from the public registries so that they always adapt to the current context.
|
||||
|
||||
:::danger
|
||||
The `UID` namespace is designed to be the main interface used by developers.
|
||||
|
||||
Do not use the `Internal.UID` namespace except if you know what you're doing (low level extends clause, isolated internal code, etc...).
|
||||
:::
|
||||
|
||||
#### Basic Example
|
||||
|
||||
A common usage is to declare a function that takes a UID as a parameter.
|
||||
|
||||
For our example, let's imagine we want to fetch an entity based on the provided resource UID.
|
||||
|
||||
```typescript
|
||||
import type { UID, Data } from '@strapi/types';
|
||||
|
||||
declare function fetch(uid: UID.ContentType): Data.ContentType;
|
||||
```
|
||||
|
||||
:::tip
|
||||
To find an exhaustive list of available UID types, take a look at the [related API reference](http://foo)
|
||||
:::
|
||||
|
||||
#### Parameter Type Inference
|
||||
|
||||
Now let's say we want to adapt the return type of our function, so that it matches the given UID.
|
||||
|
||||
```typescript
|
||||
fetch('api::article.article');
|
||||
// ^ this should return a Data.Entity<'api::article.article'>
|
||||
|
||||
fetch('admin::user');
|
||||
// ^ this should return a Data.Entity<'admin::user'>
|
||||
```
|
||||
|
||||
To do that, we'll need the function to be able to provide us with the current `uid` type based on usage.
|
||||
|
||||
```typescript
|
||||
import type { UID, Data } from '@strapi/types';
|
||||
|
||||
declare function fetch<T extends UID.ContentType>(uid: T): Data.ContentType<T>;
|
||||
```
|
||||
|
||||
So what's changed here?
|
||||
|
||||
1. We've forced the `uid` type to be inferred upon usage and stored in a type variable called `T`.
|
||||
2. We've then re-used `T` to parametrize the `Data.ContentType` type.
|
||||
|
||||
`fetch` will now always return the correct entity depending on which `UID` is sent.
|
||||
|
||||
:::caution
|
||||
When writing actual code, avoid using `T` as a type variable, and always use meaningful names that will help other developers understand what the variable represents.
|
||||
|
||||
For instance, in our example we could use `TContentTypeUID` instead of just `T`.
|
||||
:::
|
||||
|
||||
#### Going Further
|
||||
|
||||
It's completely possible to reference `T` in other generic parameters.
|
||||
|
||||
Let's add the possibility to select which fields we want to return for our entity.
|
||||
|
||||
```typescript
|
||||
import type { UID, Data, Schema } from '@strapi/types';
|
||||
|
||||
declare function fetch<T extends UID.ContentType, F extends Schema.AttributeNames<T>>(
|
||||
uid: T,
|
||||
fields: F[]
|
||||
): Data.ContentType<T>;
|
||||
```
|
||||
|
||||
:::tip
|
||||
You may have noticed that we're using the inferred UID type (`T`) to reference both:
|
||||
|
||||
- An entity (`Data.Entity<T>`)
|
||||
- A schema (`Schema.AttributeNames<T>`)
|
||||
|
||||
This is because they share the same format and can be used interchangeably.
|
||||
|
||||
For more information, take a look at the [format](#format) and [compatibility table](#compatibility-table) sections.
|
||||
:::
|
||||
@ -0,0 +1,325 @@
|
||||
---
|
||||
title: Public Registries
|
||||
tags:
|
||||
- typescript
|
||||
- type system
|
||||
- type
|
||||
- concepts
|
||||
- public
|
||||
toc_max_heading_level: 5
|
||||
---
|
||||
|
||||
import Tabs from '@theme/Tabs';
|
||||
import TabItem from '@theme/TabItem';
|
||||
|
||||
### Context
|
||||
|
||||
#### Why?
|
||||
|
||||
The Strapi Type System is designed to provide developers with a fully customizable experience.
|
||||
|
||||
It's engineered to adapt and modify its type definitions automatically according to the context of each application and associated resources.
|
||||
|
||||
This adaptability extends to various components such as schemas, services, controllers, and more.
|
||||
|
||||
:::note
|
||||
See the [type system principles](../philosophy#key-principles) page for more information about the mission.
|
||||
:::
|
||||
|
||||
#### How?
|
||||
|
||||
To meet this requirement, the Type System employs "public registries".
|
||||
|
||||
In simple terms, public registries are basic indexed interface definitions that are made publicly available by the types package.
|
||||
|
||||
```typescript
|
||||
export interface MyRegistry {
|
||||
[key: string]: GenericResourceDefinition;
|
||||
}
|
||||
```
|
||||
|
||||
Every resource comes with its own registry that has a set of rules acting as the default state (the index).
|
||||
|
||||
Because every registry can be augmented (publicly exported), developers have the freedom to inject their own definitions.
|
||||
|
||||
Doing so allows Strapi APIs to respond in a way that aligns with the developers' custom definitions.
|
||||
|
||||
:::info[Did you know?]
|
||||
The `Schema` and `UID` namespaces rely solely on public registries to infer their type definitions.
|
||||
|
||||
This is why it's heavily encouraged to use them over their low level counter-parts.
|
||||
:::
|
||||
|
||||
### How it works
|
||||
|
||||
#### Architecture
|
||||
|
||||
```mermaid
|
||||
flowchart TB;
|
||||
%% Main node
|
||||
ContentTypeRegistry("Content-Types Registry")
|
||||
|
||||
%% App
|
||||
subgraph Application
|
||||
%% Nodes
|
||||
UserApp["User Application"]
|
||||
UserSchema(["Application Types"])
|
||||
|
||||
%% Links
|
||||
UserApp -- "generates" ---> UserSchema
|
||||
end
|
||||
|
||||
%% Type System
|
||||
subgraph TypesPackage["@strapi/types"]
|
||||
%% Nodes
|
||||
TypeSystem["Type System"]
|
||||
Types{{Types}}
|
||||
Registries{{Registries}}
|
||||
UID{{UID}}
|
||||
Schema{{Schema}}
|
||||
|
||||
%% Links
|
||||
TypeSystem -- "exports" ---> Types & Registries
|
||||
Types -- "exports" ---> UID & Schema -- "uses" ---> ContentTypeRegistry
|
||||
Registries -- "exports" ----> ContentTypeRegistry
|
||||
end
|
||||
|
||||
%% Strapi
|
||||
subgraph StrapiPackage["@strapi/strapi"]
|
||||
%% Nodes
|
||||
Strapi["Strapi"]
|
||||
APIs[["APIs"]]
|
||||
|
||||
%% Links
|
||||
Strapi -- "exports" --> APIs
|
||||
APIs -- "uses" --> Types
|
||||
end
|
||||
|
||||
%% This link needs to be placed last to preserve the layout
|
||||
UserSchema -- "augments" -----> ContentTypeRegistry
|
||||
```
|
||||
|
||||
#### Usage
|
||||
|
||||
##### 1. Registry Definition
|
||||
|
||||
Creating a new registry is as simple as exporting an indexed interface from the right namespace.
|
||||
|
||||
Let's declare the content-type schema registry.
|
||||
|
||||
It should accept:
|
||||
|
||||
- Content-type UIDs as keys
|
||||
- Content-type schemas as values
|
||||
|
||||
```ts title="@strapi/types/public/registries.ts"
|
||||
import type { Internal, Struct } from '@strapi/types';
|
||||
|
||||
export interface ContentTypeSchemas {
|
||||
[key: Internal.UID.ContentType]: Struct.ContentTypeSchema;
|
||||
}
|
||||
```
|
||||
|
||||
:::note
|
||||
We use low level types to define our index (`Internal`/`Struct`) to keep it as generic as possible.
|
||||
:::
|
||||
|
||||
##### 2. Dynamic Type Definitions
|
||||
|
||||
###### UID.ContentType
|
||||
|
||||
To define `UID.ContentType`, we extract every key (`Internal.Registry.Keys`) from the public content-type registry (`Public.ContentTypeSchemas`) that matches with the base definition of a content-type UID (`Internal.UID.ContentType`).
|
||||
|
||||
```ts title="@strapi/types/uid.ts"
|
||||
import type { Internal, Public } from '@strapi/types';
|
||||
|
||||
export type ContentType = Internal.Registry.Keys<
|
||||
Public.ContentTypeSchemas,
|
||||
Internal.UID.ContentType
|
||||
>;
|
||||
```
|
||||
|
||||
:::note
|
||||
Only selecting keys that extend `Internal.UID.ContentType` ensures we don't end up with manually added malformed keys, and tells the type-checker we're confident about what's in our union type.
|
||||
:::
|
||||
|
||||
###### Schema.ContentType
|
||||
|
||||
To declare `Schema.ContentType`, we simply query the content-type schema registry (`Public.ContentTypeSchemas`) with the provided content-type UID (`TUID`).
|
||||
|
||||
:::note
|
||||
Since `UID.ContentType` (`TUID`) is [dynamically built based on actual keys](#uidcontenttype), we know for sure that there will be a valid corresponding schema in the registry.
|
||||
:::
|
||||
|
||||
```ts title="@strapi/types/schema.ts"
|
||||
import type { UID, Public } from '@strapi/types';
|
||||
|
||||
export type ContentType<TUID extends UID.ContentType> = Public.ContentTypeSchemas[TUID];
|
||||
```
|
||||
|
||||
##### 3. API Design
|
||||
|
||||
To create types for a dynamic API (_aka one that reacts to its context_), simply use dynamic type definitions such as `UID`, `Data` or `Schema`.
|
||||
|
||||
```ts title="@strapi/core/document-service.ts"
|
||||
import type { Data, UID } from '@strapi/types';
|
||||
|
||||
export type findOne<TUID extends UID.ContentType>(uid: TUID): Data.ContentType<TUID>;
|
||||
```
|
||||
|
||||
:::caution
|
||||
Remember to use dynamic type definitions (`UID`, `Data`, `Schema`) and not static ones (`Internal`, `Struct`).
|
||||
:::
|
||||
|
||||
:::info[Reminder]
|
||||
Registries are **indexed**, which means that:
|
||||
|
||||
- **When augmented** (_e.g. in users' applications_), they'll return **strongly typed values** that correspond to the defined types.
|
||||
- **When empty** (_e.g. in Strapi codebase_), they'll return **generic low level types** based on their index definition.
|
||||
|
||||
<Tabs>
|
||||
<TabItem value="augmented" label="Augmented">
|
||||
```ts
|
||||
import type { UID } from '@strapi/types';
|
||||
|
||||
const uid: UID.ContentType;
|
||||
// ^ 'api::article.article' | 'admin::user'
|
||||
```
|
||||
|
||||
</TabItem>
|
||||
<TabItem value="empty" label="Empty">
|
||||
```ts
|
||||
import type { UID } from '@strapi/types';
|
||||
|
||||
const uid: UID.ContentType;
|
||||
// ^ `admin::${string}` | `api::${string}.${string}` | `plugin::${string}.${string}` | `strapi::${string}`
|
||||
```
|
||||
|
||||
</TabItem>
|
||||
</Tabs>
|
||||
:::
|
||||
|
||||
##### 4. Type Augmentation
|
||||
|
||||
###### Manual Augmentation
|
||||
|
||||
It's possible to manually augment the public registries to create tailor-made experiences.
|
||||
|
||||
```ts title="my-app/index.d.ts"
|
||||
import type { Struct } from '@strapi/strapi';
|
||||
|
||||
interface ApiArticleArticle extends Struct.ContentTypeSchema {
|
||||
// ...
|
||||
}
|
||||
|
||||
declare module '@strapi/strapi' {
|
||||
export module Public {
|
||||
export interface ContentTypeSchemas {
|
||||
'api::article.article': ApiArticleArticle;
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
This will force every type that depends on the `Public.ContentTypeSchemas` registry to recognize `'api::article.article'` as the only valid UID and `ApiArticleArticle` the only valid schema.
|
||||
|
||||
:::note
|
||||
In the context of a Strapi application, developers are strongly encouraged to use types exported by `@strapi/strapi` and not `@strapi/types`.
|
||||
|
||||
This is to allow having both an internal (`@strapi/types`) and a public (`@strapi/strapi`) types API.
|
||||
:::
|
||||
|
||||
###### Automated Augmentation
|
||||
|
||||
To ease the registries augmentation, Strapi offers an automated way of generating types and extending the registries.
|
||||
|
||||
The process will generate type definitions based on the user application state (`schemas`), then augment the registry using the created types.
|
||||
|
||||
<Tabs>
|
||||
<TabItem value="manual" label="Manual Run">
|
||||
Generate the types once.
|
||||
|
||||
```shell title="my-app/"
|
||||
yarn strapi ts:generate-types
|
||||
```
|
||||
|
||||
</TabItem>
|
||||
<TabItem value="dev" label="During Development">
|
||||
Start the application in dev mode, and generate types on every server restart.
|
||||
|
||||
Useful when working with the content-type builder.
|
||||
|
||||
```shell title="my-app/"
|
||||
yarn develop
|
||||
```
|
||||
|
||||
</TabItem>
|
||||
</Tabs>
|
||||
|
||||
The generated types will automatically augment the corresponding registries.
|
||||
|
||||
```ts title="my-app/types/generated/contentTypes.d.ts"
|
||||
import type { Struct } from '@strapi/strapi';
|
||||
|
||||
interface ApiArticleArticle extends Struct.ContentTypeSchema {
|
||||
// ...
|
||||
}
|
||||
|
||||
interface AdminUser extends Struct.ContentTypeSchema {
|
||||
// ...
|
||||
}
|
||||
|
||||
declare module '@strapi/strapi' {
|
||||
export module Public {
|
||||
export interface ContentTypeSchemas {
|
||||
'api::article.article': ApiArticleArticle;
|
||||
'admin::user': AdminUser;
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
When coupling everything together, the end result is a TypeScript developer experience automatically adjusted to the current context.
|
||||
|
||||
<!-- prettier-ignore-start -->
|
||||
|
||||
<Tabs>
|
||||
<TabItem value="app" label="User Application">
|
||||
```ts title="my-app/src/index.ts"
|
||||
export default () => ({
|
||||
bootstrap() {
|
||||
strapi.findOne('ap');
|
||||
// ^ TypeScript will autocomplete with "api::article.article"
|
||||
|
||||
strapi.findOne('ad');
|
||||
// ^ TypeScript will autocomplete with "admin::user"
|
||||
|
||||
strapi.findOne('api::blog.blog');
|
||||
// ^ Error, TypeScript will complain
|
||||
}
|
||||
})
|
||||
```
|
||||
</TabItem>
|
||||
<TabItem value="strapi" label="Strapi Codebase">
|
||||
```ts title="@strapi/strapi/document-service.ts"
|
||||
import type { UID } from '@strapi/types';
|
||||
|
||||
export const findOne<TUID extends UID.ContentType>(uid: TUID) {
|
||||
// ...
|
||||
}
|
||||
|
||||
findOne('admin::foo');
|
||||
// ^ Valid, matches 'admin::${string}'
|
||||
|
||||
findOne('plugin::bar.bar');
|
||||
// ^ Valid, matches 'plugin::${string}.${string}'
|
||||
|
||||
findOne('baz');
|
||||
// ^ Error, does not correspond to any content-type UID format
|
||||
```
|
||||
</TabItem>
|
||||
</Tabs>
|
||||
|
||||
<!-- prettier-ignore-end -->
|
||||
15
docs/docs/guides/05-type-system/02-concepts/index.md
Normal file
15
docs/docs/guides/05-type-system/02-concepts/index.md
Normal file
@ -0,0 +1,15 @@
|
||||
---
|
||||
title: Concepts
|
||||
tags:
|
||||
- typescript
|
||||
- type system
|
||||
- type
|
||||
- concepts
|
||||
---
|
||||
|
||||
```mdx-code-block
|
||||
import DocCardList from '@theme/DocCardList';
|
||||
import { useCurrentSidebarCategory } from '@docusaurus/theme-common';
|
||||
|
||||
<DocCardList items={useCurrentSidebarCategory().items} />
|
||||
```
|
||||
@ -17,6 +17,7 @@ const config = {
|
||||
organizationName: 'strapi',
|
||||
projectName: 'strapi',
|
||||
trailingSlash: false,
|
||||
themes: ['@docusaurus/theme-mermaid'],
|
||||
|
||||
// Even if you don't use internalization, you can use this field to set useful
|
||||
// metadata like html lang. For example, if your site is Chinese, you may want
|
||||
@ -25,6 +26,9 @@ const config = {
|
||||
defaultLocale: 'en',
|
||||
locales: ['en'],
|
||||
},
|
||||
markdown: {
|
||||
mermaid: true,
|
||||
},
|
||||
plugins: [
|
||||
() => ({
|
||||
name: 'resolve-react',
|
||||
|
||||
@ -29,6 +29,7 @@
|
||||
"@cmfcmf/docusaurus-search-local": "1.1.0",
|
||||
"@docusaurus/core": "3.1.1",
|
||||
"@docusaurus/preset-classic": "3.1.1",
|
||||
"@docusaurus/theme-mermaid": "3.1.1",
|
||||
"@mdx-js/react": "^3.0.0",
|
||||
"clsx": "^1.1.1",
|
||||
"prism-react-renderer": "^2.1.0",
|
||||
|
||||
922
docs/yarn.lock
922
docs/yarn.lock
File diff suppressed because it is too large
Load Diff
@ -155,7 +155,7 @@ export default ({ action, ability, model }: any) => {
|
||||
traverseEntity(omitHiddenFields, ctx),
|
||||
// Remove not allowed fields (RBAC)
|
||||
traverseEntity(removeDisallowedFields(permittedFields), ctx),
|
||||
// Remove roles from createdBy & updateBy fields
|
||||
// Remove roles from createdBy & updatedBy fields
|
||||
omitCreatorRoles
|
||||
);
|
||||
};
|
||||
|
||||
@ -45,18 +45,18 @@ export const VersionHeader = ({ headerId }: VersionHeaderProps) => {
|
||||
|
||||
const mainFieldValue = version.data[mainField];
|
||||
|
||||
const getBackLink = (): To => {
|
||||
const getNextNavigation = (): To => {
|
||||
const pluginsQueryParams = stringify({ plugins: query.plugins }, { encode: false });
|
||||
|
||||
if (collectionType === COLLECTION_TYPES) {
|
||||
return {
|
||||
pathname: `../${collectionType}/${version.contentType}/${version.relatedDocumentId}`,
|
||||
pathname: `/content-manager/${collectionType}/${version.contentType}/${version.relatedDocumentId}`,
|
||||
search: pluginsQueryParams,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
pathname: `../${collectionType}/${version.contentType}`,
|
||||
pathname: `/content-manager/${collectionType}/${version.contentType}`,
|
||||
search: pluginsQueryParams,
|
||||
};
|
||||
};
|
||||
@ -64,16 +64,17 @@ export const VersionHeader = ({ headerId }: VersionHeaderProps) => {
|
||||
const handleRestore = async () => {
|
||||
try {
|
||||
const response = await restoreVersion({
|
||||
documentId: version.relatedDocumentId,
|
||||
collectionType,
|
||||
params: {
|
||||
versionId: version.id,
|
||||
documentId: version.relatedDocumentId,
|
||||
contentType: version.contentType,
|
||||
},
|
||||
body: { contentType: version.contentType },
|
||||
});
|
||||
|
||||
if ('data' in response) {
|
||||
navigate(`/content-manager/${collectionType}/${slug}/${response.data.data?.documentId}`);
|
||||
navigate(getNextNavigation());
|
||||
|
||||
toggleNotification({
|
||||
type: 'success',
|
||||
@ -137,7 +138,7 @@ export const VersionHeader = ({ headerId }: VersionHeaderProps) => {
|
||||
startIcon={<ArrowLeft />}
|
||||
as={NavLink}
|
||||
// @ts-expect-error - types are not inferred correctly through the as prop.
|
||||
to={getBackLink()}
|
||||
to={getNextNavigation()}
|
||||
>
|
||||
{formatMessage({
|
||||
id: 'global.back',
|
||||
|
||||
@ -80,7 +80,7 @@ describe('VersionHeader', () => {
|
||||
const backLink = screen.getByRole('link', { name: 'Back' });
|
||||
expect(backLink).toHaveAttribute(
|
||||
'href',
|
||||
'/collection-types/api::kitchensink.kitchensink/pcwmq3rlmp5w0be3cuplhnpr'
|
||||
'/content-manager/collection-types/api::kitchensink.kitchensink/pcwmq3rlmp5w0be3cuplhnpr'
|
||||
);
|
||||
});
|
||||
|
||||
@ -111,7 +111,7 @@ describe('VersionHeader', () => {
|
||||
const backLink = screen.getByRole('link', { name: 'Back' });
|
||||
expect(backLink).toHaveAttribute(
|
||||
'href',
|
||||
'/collection-types/api::kitchensink.kitchensink/pcwmq3rlmp5w0be3cuplhnpr?plugins[i18n][locale]=en'
|
||||
'/content-manager/collection-types/api::kitchensink.kitchensink/pcwmq3rlmp5w0be3cuplhnpr?plugins[i18n][locale]=en'
|
||||
);
|
||||
});
|
||||
|
||||
@ -158,7 +158,10 @@ describe('VersionHeader', () => {
|
||||
expect(await screen.findByText('Test Title (homepage)')).toBeInTheDocument();
|
||||
|
||||
const backLink = screen.getByRole('link', { name: 'Back' });
|
||||
expect(backLink).toHaveAttribute('href', '/single-types/api::homepage.homepage');
|
||||
expect(backLink).toHaveAttribute(
|
||||
'href',
|
||||
'/content-manager/single-types/api::homepage.homepage'
|
||||
);
|
||||
});
|
||||
|
||||
it('should display the correct title and subtitle for a localized entry', async () => {
|
||||
@ -185,7 +188,7 @@ describe('VersionHeader', () => {
|
||||
const backLink = screen.getByRole('link', { name: 'Back' });
|
||||
expect(backLink).toHaveAttribute(
|
||||
'href',
|
||||
'/single-types/api::homepage.homepage?plugins[i18n][locale]=en'
|
||||
'/content-manager/single-types/api::homepage.homepage?plugins[i18n][locale]=en'
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@ -1,9 +1,17 @@
|
||||
import { Data } from '@strapi/types';
|
||||
|
||||
import {
|
||||
GetHistoryVersions,
|
||||
RestoreHistoryVersion,
|
||||
} from '../../../../shared/contracts/history-versions';
|
||||
import { COLLECTION_TYPES } from '../../constants/collections';
|
||||
import { contentManagerApi } from '../../services/api';
|
||||
|
||||
interface RestoreVersion extends RestoreHistoryVersion.Request {
|
||||
documentId: Data.ID;
|
||||
collectionType?: string;
|
||||
}
|
||||
|
||||
const historyVersionsApi = contentManagerApi.injectEndpoints({
|
||||
endpoints: (builder) => ({
|
||||
getHistoryVersions: builder.query<
|
||||
@ -21,23 +29,27 @@ const historyVersionsApi = contentManagerApi.injectEndpoints({
|
||||
},
|
||||
providesTags: ['HistoryVersion'],
|
||||
}),
|
||||
restoreVersion: builder.mutation<RestoreHistoryVersion.Response, RestoreHistoryVersion.Request>(
|
||||
{
|
||||
query({ params, body }) {
|
||||
return {
|
||||
url: `/content-manager/history-versions/${params.versionId}/restore`,
|
||||
method: 'PUT',
|
||||
data: body,
|
||||
};
|
||||
},
|
||||
invalidatesTags: (_res, _error, { params }) => {
|
||||
return [
|
||||
'HistoryVersion',
|
||||
{ type: 'Document', id: `${params.contentType}_${params.documentId}` },
|
||||
];
|
||||
},
|
||||
}
|
||||
),
|
||||
restoreVersion: builder.mutation<RestoreHistoryVersion.Response, RestoreVersion>({
|
||||
query({ params, body }) {
|
||||
return {
|
||||
url: `/content-manager/history-versions/${params.versionId}/restore`,
|
||||
method: 'PUT',
|
||||
data: body,
|
||||
};
|
||||
},
|
||||
invalidatesTags: (_res, _error, { documentId, collectionType, params }) => {
|
||||
return [
|
||||
'HistoryVersion',
|
||||
{
|
||||
type: 'Document',
|
||||
id:
|
||||
collectionType === COLLECTION_TYPES
|
||||
? `${params.contentType}_${documentId}`
|
||||
: params.contentType,
|
||||
},
|
||||
];
|
||||
},
|
||||
}),
|
||||
}),
|
||||
});
|
||||
|
||||
|
||||
@ -1,199 +0,0 @@
|
||||
import { createHistoryVersionController } from '../history-version';
|
||||
|
||||
const mockFindVersionsPage = jest.fn();
|
||||
|
||||
// History utils
|
||||
jest.mock('../../utils', () => ({
|
||||
getService: jest.fn((_strapi, name) => {
|
||||
if (name === 'history') {
|
||||
return {
|
||||
findVersionsPage: mockFindVersionsPage,
|
||||
};
|
||||
}
|
||||
}),
|
||||
}));
|
||||
|
||||
// Content Manager utils
|
||||
jest.mock('../../../utils', () => ({
|
||||
getService: jest.fn((name) => {
|
||||
if (name === 'permission-checker') {
|
||||
return {
|
||||
create: jest.fn(() => ({
|
||||
cannot: {
|
||||
read: jest.fn(() => false),
|
||||
},
|
||||
sanitizeQuery: jest.fn((query) => query),
|
||||
})),
|
||||
};
|
||||
}
|
||||
}),
|
||||
}));
|
||||
|
||||
describe('History version controller', () => {
|
||||
beforeEach(() => {
|
||||
mockFindVersionsPage.mockClear();
|
||||
});
|
||||
|
||||
describe('findMany', () => {
|
||||
it('should require contentType and documentId for collection types', () => {
|
||||
const ctx = {
|
||||
state: {
|
||||
userAbility: {},
|
||||
},
|
||||
query: {},
|
||||
};
|
||||
|
||||
const historyVersionController = createHistoryVersionController({
|
||||
// @ts-expect-error - we're not mocking the entire strapi object
|
||||
strapi: { getModel: jest.fn(() => ({ kind: 'collectionType' })) },
|
||||
});
|
||||
|
||||
// @ts-expect-error partial context
|
||||
expect(historyVersionController.findMany(ctx)).rejects.toThrow(
|
||||
/contentType and documentId are required/
|
||||
);
|
||||
expect(mockFindVersionsPage).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should require contentType for single types', () => {
|
||||
const ctx = {
|
||||
state: {
|
||||
userAbility: {},
|
||||
},
|
||||
query: {},
|
||||
};
|
||||
|
||||
const historyVersionController = createHistoryVersionController({
|
||||
// @ts-expect-error - we're not mocking the entire strapi object
|
||||
strapi: { getModel: jest.fn(() => ({ kind: 'singleType' })) },
|
||||
});
|
||||
|
||||
// @ts-expect-error partial context
|
||||
expect(historyVersionController.findMany(ctx)).rejects.toThrow(/contentType is required/);
|
||||
expect(mockFindVersionsPage).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
it('should call findVersionsPage for collection types', async () => {
|
||||
const ctx = {
|
||||
state: {
|
||||
userAbility: {},
|
||||
},
|
||||
query: {
|
||||
documentId: 'document-id',
|
||||
contentType: 'api::test.test',
|
||||
},
|
||||
};
|
||||
|
||||
mockFindVersionsPage.mockResolvedValueOnce({
|
||||
results: [{ id: 'history-version-id' }],
|
||||
pagination: {
|
||||
page: 1,
|
||||
pageSize: 20,
|
||||
pageCount: 1,
|
||||
total: 0,
|
||||
},
|
||||
});
|
||||
|
||||
const historyVersionController = createHistoryVersionController({
|
||||
// @ts-expect-error - we're not mocking the entire strapi object
|
||||
strapi: { getModel: jest.fn(() => ({ kind: 'collectionType' })) },
|
||||
});
|
||||
|
||||
// @ts-expect-error partial context
|
||||
const response = await historyVersionController.findMany(ctx);
|
||||
|
||||
expect(mockFindVersionsPage).toHaveBeenCalled();
|
||||
expect(response.data.length).toBe(1);
|
||||
expect(response.meta.pagination).toBeDefined();
|
||||
});
|
||||
|
||||
it('should call findVersionsPage for single types', async () => {
|
||||
const ctx = {
|
||||
state: {
|
||||
userAbility: {},
|
||||
},
|
||||
query: {
|
||||
contentType: 'api::test.test',
|
||||
},
|
||||
};
|
||||
|
||||
mockFindVersionsPage.mockResolvedValueOnce({
|
||||
results: [{ id: 'history-version-id' }],
|
||||
pagination: {
|
||||
page: 1,
|
||||
pageSize: 20,
|
||||
pageCount: 1,
|
||||
total: 0,
|
||||
},
|
||||
});
|
||||
|
||||
const historyVersionController = createHistoryVersionController({
|
||||
// @ts-expect-error - we're not mocking the entire strapi object
|
||||
strapi: { getModel: jest.fn(() => ({ kind: 'singleType' })) },
|
||||
});
|
||||
|
||||
// @ts-expect-error partial context
|
||||
const response = await historyVersionController.findMany(ctx);
|
||||
|
||||
expect(mockFindVersionsPage).toHaveBeenCalled();
|
||||
expect(response.data.length).toBe(1);
|
||||
expect(response.meta.pagination).toBeDefined();
|
||||
});
|
||||
|
||||
it('applies pagination params', async () => {
|
||||
const ctx = {
|
||||
state: {
|
||||
userAbility: {},
|
||||
},
|
||||
query: {
|
||||
contentType: 'api::test.test',
|
||||
},
|
||||
};
|
||||
|
||||
const historyVersionController = createHistoryVersionController({
|
||||
// @ts-expect-error - we're not mocking the entire strapi object
|
||||
strapi: { getModel: jest.fn(() => ({ kind: 'singleType' })) },
|
||||
});
|
||||
|
||||
/**
|
||||
* Applies default pagination params
|
||||
*/
|
||||
mockFindVersionsPage.mockResolvedValueOnce({
|
||||
results: [],
|
||||
pagination: {
|
||||
page: 1,
|
||||
pageSize: 20,
|
||||
},
|
||||
});
|
||||
// @ts-expect-error partial context
|
||||
const mockResponse = await historyVersionController.findMany(ctx);
|
||||
expect(mockFindVersionsPage).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
page: 1,
|
||||
pageSize: 20,
|
||||
})
|
||||
);
|
||||
expect(mockResponse.meta.pagination.page).toBe(1);
|
||||
expect(mockResponse.meta.pagination.pageSize).toBe(20);
|
||||
|
||||
/**
|
||||
* Prevents invalid pagination params
|
||||
*/
|
||||
mockFindVersionsPage.mockResolvedValueOnce({
|
||||
results: [],
|
||||
pagination: {},
|
||||
});
|
||||
// @ts-expect-error partial context
|
||||
await historyVersionController.findMany({
|
||||
...ctx,
|
||||
query: { ...ctx.query, page: '-1', pageSize: '1000' },
|
||||
});
|
||||
expect(mockFindVersionsPage).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
page: 1,
|
||||
pageSize: 20,
|
||||
})
|
||||
);
|
||||
});
|
||||
});
|
||||
@ -37,13 +37,13 @@ const createHistoryVersionController = ({ strapi }: { strapi: Core.Strapi }) =>
|
||||
return {
|
||||
async findMany(ctx) {
|
||||
const contentTypeUid = ctx.query.contentType as UID.ContentType;
|
||||
const isSingleType = strapi.getModel(contentTypeUid).kind === 'singleType';
|
||||
const isSingleType = strapi.getModel(contentTypeUid)?.kind === 'singleType';
|
||||
|
||||
if (isSingleType && !contentTypeUid) {
|
||||
throw new errors.ForbiddenError('contentType is required');
|
||||
}
|
||||
|
||||
if (!contentTypeUid && !ctx.query.documentId) {
|
||||
if (!isSingleType && (!contentTypeUid || !ctx.query.documentId)) {
|
||||
throw new errors.ForbiddenError('contentType and documentId are required');
|
||||
}
|
||||
|
||||
@ -68,7 +68,15 @@ const createHistoryVersionController = ({ strapi }: { strapi: Core.Strapi }) =>
|
||||
...getValidPagination({ page: params.page, pageSize: params.pageSize }),
|
||||
});
|
||||
|
||||
return { data: results, meta: { pagination } };
|
||||
return {
|
||||
data: await Promise.all(
|
||||
results.map(async (result) => ({
|
||||
...result,
|
||||
data: await permissionChecker.sanitizeOutput(result.data),
|
||||
}))
|
||||
),
|
||||
meta: { pagination },
|
||||
};
|
||||
},
|
||||
|
||||
async restoreVersion(ctx) {
|
||||
|
||||
@ -47,6 +47,9 @@ const createHistoryService = ({ strapi }: { strapi: Core.Strapi }) => {
|
||||
};
|
||||
|
||||
const localesService = strapi.plugin('i18n')?.service('locales');
|
||||
|
||||
const getDefaultLocale = async () => (localesService ? localesService.getDefaultLocale() : null);
|
||||
|
||||
const getLocaleDictionary = async () => {
|
||||
if (!localesService) return {};
|
||||
|
||||
@ -163,8 +166,9 @@ const createHistoryService = ({ strapi }: { strapi: Core.Strapi }) => {
|
||||
? { documentId: result.documentId, locale: context.params?.locale }
|
||||
: { documentId: context.params.documentId, locale: context.params?.locale };
|
||||
|
||||
const defaultLocale = localesService ? await localesService.getDefaultLocale() : null;
|
||||
const defaultLocale = await getDefaultLocale();
|
||||
const locale = documentContext.locale || defaultLocale;
|
||||
|
||||
const document = await strapi.documents(contentTypeUid).findOne({
|
||||
documentId: documentContext.documentId,
|
||||
locale,
|
||||
@ -251,6 +255,7 @@ const createHistoryService = ({ strapi }: { strapi: Core.Strapi }) => {
|
||||
results: HistoryVersions.HistoryVersionDataResponse[];
|
||||
pagination: HistoryVersions.Pagination;
|
||||
}> {
|
||||
const locale = params.locale || (await getDefaultLocale());
|
||||
const [{ results, pagination }, localeDictionary] = await Promise.all([
|
||||
query.findPage({
|
||||
...params,
|
||||
@ -258,7 +263,7 @@ const createHistoryService = ({ strapi }: { strapi: Core.Strapi }) => {
|
||||
$and: [
|
||||
{ contentType: params.contentType },
|
||||
...(params.documentId ? [{ relatedDocumentId: params.documentId }] : []),
|
||||
...(params.locale ? [{ locale: params.locale }] : []),
|
||||
...(locale ? [{ locale }] : []),
|
||||
],
|
||||
},
|
||||
populate: ['createdBy'],
|
||||
@ -497,6 +502,7 @@ const createHistoryService = ({ strapi }: { strapi: Core.Strapi }) => {
|
||||
const data = omit(['id', ...Object.keys(schemaDiff.removed)], dataWithoutMissingRelations);
|
||||
const restoredDocument = await strapi.documents(version.contentType).update({
|
||||
documentId: version.relatedDocumentId,
|
||||
locale: version.locale,
|
||||
data,
|
||||
});
|
||||
|
||||
|
||||
@ -16,7 +16,7 @@ export interface CreateHistoryVersion {
|
||||
componentsSchemas: Record<`${string}.${string}`, Struct.SchemaAttributes>;
|
||||
}
|
||||
|
||||
interface Locale {
|
||||
export interface Locale {
|
||||
name: string;
|
||||
code: string;
|
||||
}
|
||||
@ -82,7 +82,6 @@ export declare namespace RestoreHistoryVersion {
|
||||
export interface Request {
|
||||
params: {
|
||||
versionId: Data.ID;
|
||||
documentId: Data.ID;
|
||||
contentType: UID.ContentType;
|
||||
};
|
||||
body: {
|
||||
|
||||
@ -142,12 +142,39 @@ const objectParam_10 = [...objectParam_10_2];
|
||||
|
||||
strapi.entityService.findOne(...[...objectParam_10]);
|
||||
|
||||
Case: find, create, update, delete with entityId as first argument
|
||||
|
||||
strapi.entityService.findMany(uid, {
|
||||
fields: ["id", "name", "description"],
|
||||
populate: ["author", "comments"],
|
||||
publicationState: "preview",
|
||||
});
|
||||
|
||||
strapi.entityService.create(uid, {
|
||||
data: {
|
||||
name: "John Doe",
|
||||
age: 30,
|
||||
},
|
||||
});
|
||||
|
||||
strapi.entityService.update(uid, entityId, {
|
||||
data: {
|
||||
name: "John Doe",
|
||||
age: 30,
|
||||
},
|
||||
});
|
||||
|
||||
strapi.entityService.delete(uid, entityId);
|
||||
strapi.entityService.findOne(uid, entityId);
|
||||
|
||||
*/
|
||||
|
||||
const movedFunctions = ['findOne', 'find', 'count', 'create', 'update', 'delete'];
|
||||
const movedFunctions = ['findOne', 'findMany', 'count', 'create', 'update', 'delete'];
|
||||
|
||||
const functionsWithEntityId = ['findOne', 'update', 'delete'];
|
||||
|
||||
const transformDeclaration = (path: ASTPath<any>, name: any, j: JSCodeshift) => {
|
||||
const declaration = findClosesDeclaration(path, name, j);
|
||||
const declaration = findClosestDeclaration(path, name, j);
|
||||
|
||||
if (!declaration) {
|
||||
return;
|
||||
@ -222,7 +249,7 @@ const transformObjectParam = (path: ASTPath<any>, expression: ObjectExpression,
|
||||
break;
|
||||
}
|
||||
case j.Identifier.check(prop.value): {
|
||||
const declaration = findClosesDeclaration(path, prop.value.name, j);
|
||||
const declaration = findClosestDeclaration(path, prop.value.name, j);
|
||||
|
||||
if (!declaration) {
|
||||
return;
|
||||
@ -253,7 +280,7 @@ const transformObjectParam = (path: ASTPath<any>, expression: ObjectExpression,
|
||||
});
|
||||
};
|
||||
|
||||
const findClosesDeclaration = (path: ASTPath<any>, name: string, j) => {
|
||||
const findClosestDeclaration = (path: ASTPath<any>, name: string, j) => {
|
||||
// find Identifier declaration
|
||||
const scope = path.scope.lookup(name);
|
||||
|
||||
@ -318,7 +345,7 @@ const transform: Transform = (file, api) => {
|
||||
case j.Identifier.check(arg.argument): {
|
||||
const identifier = arg.argument;
|
||||
|
||||
const declaration = findClosesDeclaration(path, identifier.name, j);
|
||||
const declaration = findClosestDeclaration(path, identifier.name, j);
|
||||
|
||||
if (!declaration) {
|
||||
return arg;
|
||||
@ -351,6 +378,42 @@ const transform: Transform = (file, api) => {
|
||||
|
||||
const [docUID, ...rest] = resolvedArgs;
|
||||
|
||||
// function with entityId as first argument
|
||||
if (
|
||||
j.Identifier.check(path.value.callee.property) &&
|
||||
functionsWithEntityId.includes(path.value.callee.property.name)
|
||||
) {
|
||||
rest.splice(0, 1);
|
||||
|
||||
// in case no extra params are passed in the function e.g delete(uid, entityId)
|
||||
if (rest.length === 0) {
|
||||
rest.push(
|
||||
j.objectExpression.from({
|
||||
properties: [],
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
const params = rest[0];
|
||||
|
||||
const placeholder = j.objectProperty(j.identifier('documentId'), j.literal('__TODO__'));
|
||||
|
||||
// add documentId to params with a placeholder
|
||||
if (j.ObjectExpression.check(params)) {
|
||||
params.properties.unshift(placeholder);
|
||||
} else if (j.Identifier.check(params)) {
|
||||
const declaration = findClosestDeclaration(path, params.name, j);
|
||||
|
||||
if (!declaration) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (j.ObjectExpression.check(declaration.init)) {
|
||||
declaration.init.properties.unshift(placeholder);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
path.value.arguments.forEach((arg) => {
|
||||
transformElement(path, arg, j);
|
||||
});
|
||||
|
||||
@ -0,0 +1,442 @@
|
||||
import { createStrapiInstance } from 'api-tests/strapi';
|
||||
import { createAuthRequest } from 'api-tests/request';
|
||||
import { createUtils, describeOnCondition } from 'api-tests/utils';
|
||||
import { createTestBuilder } from 'api-tests/builder';
|
||||
|
||||
const edition = process.env.STRAPI_DISABLE_EE === 'true' ? 'CE' : 'EE';
|
||||
|
||||
const collectionTypeUid = 'api::product.product';
|
||||
const collectionTypeModel = {
|
||||
draftAndPublish: true,
|
||||
singularName: 'product',
|
||||
pluralName: 'products',
|
||||
displayName: 'Product',
|
||||
kind: 'collectionType',
|
||||
pluginOptions: {
|
||||
i18n: {
|
||||
localized: true,
|
||||
},
|
||||
},
|
||||
attributes: {
|
||||
name: {
|
||||
type: 'string',
|
||||
pluginOptions: {
|
||||
i18n: {
|
||||
localized: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
description: {
|
||||
type: 'string',
|
||||
pluginOptions: {
|
||||
i18n: {
|
||||
localized: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const singleTypeUid = 'api::homepage.homepage';
|
||||
const singleTypeModel = {
|
||||
draftAndPublish: true,
|
||||
singularName: 'homepage',
|
||||
pluralName: 'homepages',
|
||||
displayName: 'Homepage',
|
||||
kind: 'singleType',
|
||||
pluginOptions: {
|
||||
i18n: {
|
||||
localized: true,
|
||||
},
|
||||
},
|
||||
attributes: {
|
||||
title: {
|
||||
type: 'string',
|
||||
pluginOptions: {
|
||||
i18n: {
|
||||
localized: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
subtitle: {
|
||||
type: 'string',
|
||||
pluginOptions: {
|
||||
i18n: {
|
||||
localized: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
interface CreateEntryArgs {
|
||||
uid: string;
|
||||
data: Record<string, unknown>;
|
||||
isCollectionType?: boolean;
|
||||
}
|
||||
|
||||
interface UpdateEntryArgs extends CreateEntryArgs {
|
||||
documentId?: string;
|
||||
locale?: string;
|
||||
}
|
||||
|
||||
describeOnCondition(edition === 'EE')('History API', () => {
|
||||
const builder = createTestBuilder();
|
||||
let strapi;
|
||||
let rq;
|
||||
let collectionTypeDocumentId;
|
||||
let singleTypeDocumentId;
|
||||
|
||||
const createEntry = async ({ uid, data, isCollectionType = true }: CreateEntryArgs) => {
|
||||
const type = isCollectionType ? 'collection-types' : 'single-types';
|
||||
|
||||
const { body } = await rq({
|
||||
method: 'POST',
|
||||
url: `/content-manager/${type}/${uid}`,
|
||||
body: data,
|
||||
});
|
||||
|
||||
return body;
|
||||
};
|
||||
|
||||
const updateEntry = async ({ uid, documentId, data, locale }: UpdateEntryArgs) => {
|
||||
const type = documentId ? 'collection-types' : 'single-types';
|
||||
const params = documentId ? `${type}/${uid}/${documentId}` : `${type}/${uid}`;
|
||||
|
||||
const { body } = await rq({
|
||||
method: 'PUT',
|
||||
url: `/content-manager/${params}`,
|
||||
body: data,
|
||||
qs: { locale },
|
||||
});
|
||||
|
||||
return body;
|
||||
};
|
||||
|
||||
const createUserAndReq = async (
|
||||
userName: string,
|
||||
permissions: { action: string; subject: string }[]
|
||||
) => {
|
||||
const utils = createUtils(strapi);
|
||||
const role = await utils.createRole({
|
||||
name: `role-${userName}`,
|
||||
description: `Role with restricted permissions for ${userName}`,
|
||||
});
|
||||
|
||||
const rolePermissions = await utils.assignPermissionsToRole(role.id, permissions);
|
||||
Object.assign(role, { permissions: rolePermissions });
|
||||
|
||||
const user = await utils.createUser({
|
||||
firstname: userName,
|
||||
lastname: 'User',
|
||||
email: `${userName}.user@strapi.io`,
|
||||
roles: [role.id],
|
||||
});
|
||||
|
||||
const rq = await createAuthRequest({ strapi, userInfo: user });
|
||||
|
||||
return rq;
|
||||
};
|
||||
|
||||
beforeAll(async () => {
|
||||
await builder.addContentTypes([collectionTypeModel, singleTypeModel]).build();
|
||||
|
||||
strapi = await createStrapiInstance();
|
||||
rq = await createAuthRequest({ strapi });
|
||||
|
||||
// Create another locale
|
||||
const localeService = strapi.plugin('i18n').service('locales');
|
||||
await localeService.create({ code: 'fr', name: 'French' });
|
||||
|
||||
// Create a collection type to create an initial history version
|
||||
const collectionType = await createEntry({
|
||||
uid: collectionTypeUid,
|
||||
data: {
|
||||
name: 'Product 1',
|
||||
},
|
||||
});
|
||||
|
||||
// Update the single type to create an initial history version
|
||||
const singleType = await updateEntry({
|
||||
uid: singleTypeUid,
|
||||
data: {
|
||||
title: 'Welcome',
|
||||
},
|
||||
isCollectionType: false,
|
||||
});
|
||||
// Set the documentIds to test
|
||||
collectionTypeDocumentId = collectionType.data.documentId;
|
||||
singleTypeDocumentId = singleType.data.documentId;
|
||||
|
||||
// Update to create history versions for entries in different locales
|
||||
await Promise.all([
|
||||
updateEntry({
|
||||
documentId: collectionTypeDocumentId,
|
||||
uid: collectionTypeUid,
|
||||
data: {
|
||||
description: 'Hello',
|
||||
},
|
||||
}),
|
||||
updateEntry({
|
||||
documentId: collectionTypeDocumentId,
|
||||
uid: collectionTypeUid,
|
||||
locale: 'fr',
|
||||
data: {
|
||||
name: 'Produit 1',
|
||||
},
|
||||
}),
|
||||
updateEntry({
|
||||
documentId: collectionTypeDocumentId,
|
||||
uid: collectionTypeUid,
|
||||
locale: 'fr',
|
||||
data: {
|
||||
description: 'Coucou',
|
||||
},
|
||||
}),
|
||||
updateEntry({
|
||||
uid: singleTypeUid,
|
||||
data: {
|
||||
description: 'Wow, amazing!',
|
||||
},
|
||||
isCollectionType: false,
|
||||
}),
|
||||
updateEntry({
|
||||
uid: singleTypeUid,
|
||||
data: {
|
||||
title: 'Bienvenue',
|
||||
},
|
||||
isCollectionType: false,
|
||||
locale: 'fr',
|
||||
}),
|
||||
updateEntry({
|
||||
uid: singleTypeUid,
|
||||
data: {
|
||||
description: 'Super',
|
||||
},
|
||||
isCollectionType: false,
|
||||
locale: 'fr',
|
||||
}),
|
||||
]);
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await strapi.destroy();
|
||||
await builder.cleanup();
|
||||
});
|
||||
|
||||
describe('Find many history versions', () => {
|
||||
test('A collection type throws with invalid query params', async () => {
|
||||
const noDocumentId = await rq({
|
||||
method: 'GET',
|
||||
url: `/content-manager/history-versions/?contentType=${collectionTypeUid}`,
|
||||
});
|
||||
|
||||
const noContentTypeUid = await rq({
|
||||
method: 'GET',
|
||||
url: `/content-manager/history-versions/?documentId=${collectionTypeDocumentId}`,
|
||||
});
|
||||
|
||||
expect(noDocumentId.statusCode).toBe(403);
|
||||
expect(noContentTypeUid.statusCode).toBe(403);
|
||||
});
|
||||
|
||||
test('A single type throws with invalid query params', async () => {
|
||||
const singleTypeNoContentTypeUid = await rq({
|
||||
method: 'GET',
|
||||
url: `/content-manager/history-versions/`,
|
||||
});
|
||||
|
||||
expect(singleTypeNoContentTypeUid.statusCode).toBe(403);
|
||||
});
|
||||
|
||||
test('Throws without read permissions', async () => {
|
||||
const restrictedRq = await createUserAndReq('restricted', []);
|
||||
const res = await restrictedRq({
|
||||
method: 'GET',
|
||||
url: `/content-manager/history-versions/?contentType=${collectionTypeUid}&documentId=${collectionTypeDocumentId}`,
|
||||
});
|
||||
|
||||
expect(res.statusCode).toBe(403);
|
||||
});
|
||||
|
||||
test('A collection type finds many versions in the default locale', async () => {
|
||||
const collectionType = await rq({
|
||||
method: 'GET',
|
||||
url: `/content-manager/history-versions/?contentType=${collectionTypeUid}&documentId=${collectionTypeDocumentId}`,
|
||||
});
|
||||
|
||||
expect(collectionType.statusCode).toBe(200);
|
||||
expect(collectionType.body.data).toHaveLength(2);
|
||||
expect(collectionType.body.data[0].relatedDocumentId).toBe(collectionTypeDocumentId);
|
||||
expect(collectionType.body.data[1].relatedDocumentId).toBe(collectionTypeDocumentId);
|
||||
expect(collectionType.body.data[0].locale.code).toBe('en');
|
||||
expect(collectionType.body.data[1].locale.code).toBe('en');
|
||||
expect(collectionType.body.meta.pagination).toEqual({
|
||||
page: 1,
|
||||
pageSize: 20,
|
||||
pageCount: 1,
|
||||
total: 2,
|
||||
});
|
||||
});
|
||||
|
||||
test('A collection type finds many versions in the provided locale', async () => {
|
||||
const collectionType = await rq({
|
||||
method: 'GET',
|
||||
url: `/content-manager/history-versions/?contentType=${collectionTypeUid}&documentId=${collectionTypeDocumentId}&locale=fr`,
|
||||
});
|
||||
|
||||
expect(collectionType.statusCode).toBe(200);
|
||||
expect(collectionType.body.data).toHaveLength(2);
|
||||
expect(collectionType.body.data[0].relatedDocumentId).toBe(collectionTypeDocumentId);
|
||||
expect(collectionType.body.data[1].relatedDocumentId).toBe(collectionTypeDocumentId);
|
||||
expect(collectionType.body.data[0].locale.code).toBe('fr');
|
||||
expect(collectionType.body.data[1].locale.code).toBe('fr');
|
||||
expect(collectionType.body.meta.pagination).toEqual({
|
||||
page: 1,
|
||||
pageSize: 20,
|
||||
pageCount: 1,
|
||||
total: 2,
|
||||
});
|
||||
});
|
||||
|
||||
test('A single type finds many versions in the default locale', async () => {
|
||||
const singleType = await rq({
|
||||
method: 'GET',
|
||||
url: `/content-manager/history-versions/?contentType=${singleTypeUid}`,
|
||||
});
|
||||
|
||||
expect(singleType.statusCode).toBe(200);
|
||||
expect(singleType.body.data).toHaveLength(2);
|
||||
expect(singleType.body.data[0].relatedDocumentId).toBe(singleTypeDocumentId);
|
||||
expect(singleType.body.data[1].relatedDocumentId).toBe(singleTypeDocumentId);
|
||||
expect(singleType.body.data[0].locale.code).toBe('en');
|
||||
expect(singleType.body.data[1].locale.code).toBe('en');
|
||||
expect(singleType.body.meta.pagination).toEqual({
|
||||
page: 1,
|
||||
pageSize: 20,
|
||||
pageCount: 1,
|
||||
total: 2,
|
||||
});
|
||||
});
|
||||
|
||||
test('A single type finds many versions in the provided locale', async () => {
|
||||
const singleType = await rq({
|
||||
method: 'GET',
|
||||
url: `/content-manager/history-versions/?contentType=${singleTypeUid}&locale=fr`,
|
||||
});
|
||||
|
||||
expect(singleType.statusCode).toBe(200);
|
||||
expect(singleType.body.data).toHaveLength(2);
|
||||
expect(singleType.body.data[0].relatedDocumentId).toBe(singleTypeDocumentId);
|
||||
expect(singleType.body.data[1].relatedDocumentId).toBe(singleTypeDocumentId);
|
||||
expect(singleType.body.data[0].locale.code).toBe('fr');
|
||||
expect(singleType.body.data[1].locale.code).toBe('fr');
|
||||
expect(singleType.body.meta.pagination).toEqual({
|
||||
page: 1,
|
||||
pageSize: 20,
|
||||
pageCount: 1,
|
||||
total: 2,
|
||||
});
|
||||
});
|
||||
|
||||
test('Applies pagination params', async () => {
|
||||
const collectionType = await rq({
|
||||
method: 'GET',
|
||||
url: `/content-manager/history-versions/?contentType=${collectionTypeUid}&documentId=${collectionTypeDocumentId}&page=1&pageSize=1`,
|
||||
});
|
||||
|
||||
expect(collectionType.body.data).toHaveLength(1);
|
||||
expect(collectionType.body.meta.pagination).toEqual({
|
||||
page: 1,
|
||||
pageSize: 1,
|
||||
pageCount: 2,
|
||||
total: 2,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('Restore a history version', () => {
|
||||
test('Throws with invalid body', async () => {
|
||||
const res = await rq({
|
||||
method: 'PUT',
|
||||
url: `/content-manager/history-versions/1/restore`,
|
||||
body: {},
|
||||
});
|
||||
|
||||
expect(res.statusCode).toBe(400);
|
||||
expect(res.body).toMatchObject({
|
||||
data: null,
|
||||
error: {
|
||||
status: 400,
|
||||
name: 'ValidationError',
|
||||
message: 'contentType is required',
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
test('Throws without update permissions', async () => {
|
||||
const restrictedRq = await createUserAndReq('read', [
|
||||
{ action: 'plugin::content-manager.explorer.read', subject: collectionTypeUid },
|
||||
]);
|
||||
const res = await restrictedRq({
|
||||
method: 'PUT',
|
||||
url: `/content-manager/history-versions/1/restore`,
|
||||
body: {
|
||||
contentType: collectionTypeUid,
|
||||
},
|
||||
});
|
||||
|
||||
expect(res.statusCode).toBe(403);
|
||||
expect(res.body).toMatchObject({
|
||||
data: null,
|
||||
error: {
|
||||
status: 403,
|
||||
name: 'ForbiddenError',
|
||||
message: 'Forbidden',
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
test('Restores a history version in the default locale', async () => {
|
||||
const currentDocument = await strapi
|
||||
.documents(collectionTypeUid)
|
||||
.findOne({ documentId: collectionTypeDocumentId });
|
||||
|
||||
await rq({
|
||||
method: 'PUT',
|
||||
url: `/content-manager/history-versions/1/restore`,
|
||||
body: {
|
||||
contentType: collectionTypeUid,
|
||||
},
|
||||
});
|
||||
|
||||
const restoredDocument = await strapi
|
||||
.documents(collectionTypeUid)
|
||||
.findOne({ documentId: collectionTypeDocumentId });
|
||||
|
||||
expect(currentDocument.description).toBe('Hello');
|
||||
expect(restoredDocument.description).toBe(null);
|
||||
});
|
||||
|
||||
test('Restores a history version in the provided locale', async () => {
|
||||
const currentDocument = await strapi
|
||||
.documents(collectionTypeUid)
|
||||
.findOne({ documentId: collectionTypeDocumentId, locale: 'fr' });
|
||||
|
||||
await rq({
|
||||
method: 'PUT',
|
||||
url: `/content-manager/history-versions/4/restore`,
|
||||
body: {
|
||||
contentType: collectionTypeUid,
|
||||
},
|
||||
});
|
||||
|
||||
const restoredDocument = await strapi
|
||||
.documents(collectionTypeUid)
|
||||
.findOne({ documentId: collectionTypeDocumentId, locale: 'fr' });
|
||||
|
||||
expect(currentDocument.description).toBe('Coucou');
|
||||
expect(restoredDocument.description).toBe(null);
|
||||
});
|
||||
});
|
||||
});
|
||||
Loading…
x
Reference in New Issue
Block a user