Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
27 changes: 27 additions & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,33 @@ When new backend features are discovered, update `ai-context/core-features-refer
- A React hook that returns a presenter carries the `Presenter` suffix, matching `useContentEntryFormPresenter` (e.g. `useScheduledActionsPresenter`). Resolve a presenter through such a dedicated hook — do not repeat inline `container.resolve(SomePresenter)` across components
- Do NOT define additional React components inline in a hook file (or any file whose primary export is not that component). Extract each component to its own file, named after it (e.g. a schedule dialog hook keeps `ReschedulingAlert`, `FormComponent`, etc. in separate files)
- When refactoring, we don't care about backwards compatibility, unless explicitly stated in the prompt
- Prefer several short lines over one densely-inlined expression. Break chained/nested calls and object literals across multiple lines so each step is readable; do not cram a whole transform onto a single line

## Cross-cutting formatting/utility features

Cross-cutting formatting or string-utility logic (date formatting, slugifying, and similar helpers that would otherwise be imported ad-hoc in many places) MUST be an injectable, decoratable DI feature — never a bare imported util. This lets projects override the behavior globally by decorating the abstraction, instead of intercepting it at the bundler level.

- Home such features in `@webiny/app-admin` (`src/features/<name>/`) so any admin module can use them. Register the default implementation with the core features in `src/base/Admin.tsx` so it is always available.
- Structure: `createAbstraction` (abstraction) + `createImplementation` (default impl, holding the canonical options) + `createFeature` (registers the impl) + a `use<Name>` hook. Mirror `features/stringFormatter` / `features/dateFormatter`.
- Group related transforms behind one broad, consumer-facing feature (e.g. `StringFormatter`, whose methods will grow over time), but keep each transform's logic in its own small, single-method decoratable feature that the broad one delegates to. Example: `StringFormatter.slugify()` calls `Slugify.execute()` internally, so a project changes slug logic by decorating `Slugify` alone — a smaller surface than decorating the whole formatter.
- Consumers depend on the broad feature (`StringFormatter`, `DateFormatter`); the fine-grained transform feature (`Slugify`) is an internal dependency of the broad one and the decorate seam. Do the formatting in a presenter (expose the formatted string on the view model). Only presenter-less components resolve the feature through the `use<Name>` hook.
- Inject the abstraction into a presenter via its `dependencies` array; do not import the bare util.
- Exceptions — keep using a plain util when the value must stay stable regardless of project overrides (e.g. internally-generated keys), or when the consumer is a lower-level package that cannot depend on `@webiny/app-admin`.

To override a feature's behavior for a project, decorate the abstraction — written across multiple lines, not inlined:

```ts
const MyDateFormat = DateFormatter.createDecorator(() => {
return {
format: date => {
const formatter = new Intl.DateTimeFormat("en-GB", { dateStyle: "medium" });
return formatter.format(new Date(date));
}
};
});

container.registerDecorator(MyDateFormat);
```

## Building

Expand Down
1 change: 0 additions & 1 deletion packages/app-aco/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,6 @@
"mobx-react-lite": "^4.1.1",
"react": "18.3.1",
"react-dom": "18.3.1",
"slugify": "^1.6.9",
"zod": "4.4.3"
},
"devDependencies": {
Expand Down
13 changes: 3 additions & 10 deletions packages/app-aco/src/dialogs/useCreateDialog.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import React, { useCallback, useState } from "react";
import slugify from "slugify";
import { useStringFormatter } from "@webiny/app-admin/features/stringFormatter/useStringFormatter.js";
import { Grid, Input } from "@webiny/admin-ui";
import { useDialogs, useSnackbar } from "@webiny/app-admin";
import type { GenericFormData } from "@webiny/form";
Expand All @@ -26,22 +26,15 @@ interface FormComponentProps {
const FormComponent = ({ currentParentId = null }: FormComponentProps) => {
const [parentId, setParentId] = useState<string | null>(currentParentId);
const form = useForm();
const stringFormatter = useStringFormatter();

const generateSlug = () => {
if (form.data.slug || !form.data.title) {
return;
}

// We want to update slug only when the folder is first being created.
form.setValue(
"slug",
slugify(form.data.title, {
replacement: "-",
lower: true,
remove: /[*#?<>_{}[\]+~.()'"!:;@]/g,
trim: false
})
);
form.setValue("slug", stringFormatter.slugify(form.data.title));
};

return (
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import { ListFoldersByParentIdsUseCase } from "~/features/folders/listFoldersByP
import { GetFolderAncestorsUseCase } from "~/features/folders/getFolderAncestors/abstractions.js";
import { GetFolderLevelPermissionUseCase } from "~/features/folders/getFolderLevelPermission/abstractions.js";
import { FormModelFactory } from "@webiny/app-admin/features/formModel/abstractions.js";
import { StringFormatter } from "@webiny/app-admin/features/stringFormatter/abstractions.js";
import { ListCache } from "~/features/folders/cache/index.js";
import { Folder } from "~/domain/folder/Folder.js";
import type {
Expand Down Expand Up @@ -191,6 +192,9 @@ function createTestPresenter(folders: Folder[] = []) {
FormModelFactory,
formModelFactory as unknown as FormModelFactory.Interface
);
container.registerInstance(StringFormatter, {
slugify: (value: string) => value
});

FolderTreePresenterFeature.register(container);

Expand Down
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { makeAutoObservable, reaction, runInAction } from "mobx";
import slugify from "slugify";
import { StringFormatter } from "@webiny/app-admin/features/stringFormatter/abstractions.js";
import {
FolderTreePresenter as Abstraction,
type IFolderTreeNode,
Expand Down Expand Up @@ -40,7 +40,8 @@ class FolderTreePresenterImpl implements Abstraction.Interface {
private deleteFolderUseCase: DeleteFolderUseCase.Interface,
private getFolderAncestorsUseCase: GetFolderAncestorsUseCase.Interface,
private getFolderLevelPermissionUseCase: GetFolderLevelPermissionUseCase.Interface,
private formModelFactory: FormModelFactory.Interface
private formModelFactory: FormModelFactory.Interface,
private stringFormatter: StringFormatter.Interface
) {
makeAutoObservable<FolderTreePresenterImpl, "callbacks">(
this,
Expand Down Expand Up @@ -158,12 +159,7 @@ class FolderTreePresenterImpl implements Abstraction.Interface {
.required("Slug is required")
.computedUntilDirty(({ form }) => {
const title = form.field("title").getValue();
return slugify(String(title ?? ""), {
replacement: "-",
lower: true,
remove: /[*#\?<>_\{\}\[\]+~.()'"!:;@]/g,
trim: false
});
return this.stringFormatter.slugify(String(title ?? ""));
}),
parentId: fields
.text()
Expand Down Expand Up @@ -199,12 +195,7 @@ class FolderTreePresenterImpl implements Abstraction.Interface {
.required("Slug is required")
.computedUntilDirty(({ form }) => {
const title = form.field("title").getValue();
return slugify(String(title ?? ""), {
replacement: "-",
lower: true,
remove: /[*#\?<>_\{\}\[\]+~.()'"!:;@]/g,
trim: false
});
return this.stringFormatter.slugify(String(title ?? ""));
})
}),
layout: layout => [layout.row("title"), layout.row("slug")]
Expand Down Expand Up @@ -371,6 +362,7 @@ export const FolderTreePresenter = Abstraction.createImplementation({
DeleteFolderUseCase,
GetFolderAncestorsUseCase,
GetFolderLevelPermissionUseCase,
FormModelFactory
FormModelFactory,
StringFormatter
]
});
2 changes: 2 additions & 0 deletions packages/app-admin/src/base/Admin.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ import { FormModelFeature } from "~/features/formModel/feature.js";
import type { PluginCollection } from "@webiny/plugins/types.js";
import { AdminConfigPlugin, AdminConfigProvider } from "~/config/AdminConfig.js";
import { WebinySdkFeature } from "~/features/webinySdk/feature.js";
import { StringFormatterFeature } from "~/features/stringFormatter/feature.js";
import { ListPresenterFeature } from "~/presentation/listPresenter/index.js";
import { SortableFeature } from "~/presentation/sortable/index.js";
import { NotificationsRenderer } from "~/features/notifications/NotificationsRenderer.js";
Expand All @@ -43,6 +44,7 @@ export const Admin = ({ children, createApolloClient, createLegacyPlugins }: Adm

ApolloClientFeature.register(container, apolloClient);
SecurityFeature.register(container);
StringFormatterFeature.register(container);
FormModelFeature.register(container);
WebinySdkFeature.register(container);
ListPresenterFeature.register(container);
Expand Down
26 changes: 26 additions & 0 deletions packages/app-admin/src/features/slugify/DefaultSlugify.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
import baseSlugify from "slugify";
import { Slugify } from "./abstractions.js";
import type { ISlugify } from "./abstractions.js";

/**
* Webiny's canonical slug options. These were previously duplicated at every call site; keeping them
* in one private place means projects can change slug generation everywhere at once by decorating
* Slugify — not by passing per-call options, so the abstraction stays independent of this library.
*/
const DEFAULT_OPTIONS = {
replacement: "-",
lower: true,
remove: /[*#?<>_{}[\]+~.()'"!:;@]/g,
trim: false
};

class DefaultSlugifyImpl implements ISlugify {
execute(value: string): string {
return baseSlugify(value, DEFAULT_OPTIONS);
}
}

export const DefaultSlugify = Slugify.createImplementation({
implementation: DefaultSlugifyImpl,
dependencies: []
});
16 changes: 16 additions & 0 deletions packages/app-admin/src/features/slugify/abstractions.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
import { createAbstraction } from "@webiny/feature/admin";

export interface ISlugify {
/**
* Turns a value into a URL-friendly slug using Webiny's canonical options. This is the
* fine-grained seam behind `StringFormatter.slugify()` — decorate `Slugify` alone to change slug
* logic without touching the rest of the string formatter.
*/
execute(value: string): string;
}

export const Slugify = createAbstraction<ISlugify>("Slugify");

export namespace Slugify {
export type Interface = ISlugify;
}
9 changes: 9 additions & 0 deletions packages/app-admin/src/features/slugify/feature.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
import { createFeature } from "@webiny/feature/admin";
import { DefaultSlugify } from "./DefaultSlugify.js";

export const SlugifyFeature = createFeature({
name: "Slugify",
register(container) {
container.register(DefaultSlugify).inSingletonScope();
}
});
3 changes: 3 additions & 0 deletions packages/app-admin/src/features/slugify/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
export * from "./abstractions.js";
export * from "./DefaultSlugify.js";
export * from "./feature.js";
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
import { Slugify } from "~/features/slugify/abstractions.js";
import { StringFormatter } from "./abstractions.js";
import type { IStringFormatter } from "./abstractions.js";

/**
* The string formatter is the consumer-facing API for string transforms. It keeps each transform's
* logic in its own fine-grained, decoratable feature (e.g. `Slugify`) and just delegates to it, so a
* project can change one transform without reimplementing the whole formatter. More methods will be
* added here over time.
*/
class DefaultStringFormatterImpl implements IStringFormatter {
constructor(private readonly slugifier: Slugify.Interface) {}

slugify(value: string): string {
return this.slugifier.execute(value);
}
}

export const DefaultStringFormatter = StringFormatter.createImplementation({
implementation: DefaultStringFormatterImpl,
dependencies: [Slugify]
});
15 changes: 15 additions & 0 deletions packages/app-admin/src/features/stringFormatter/abstractions.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
import { createAbstraction } from "@webiny/feature/admin";

export interface IStringFormatter {
/**
* Turns a value into a URL-friendly slug. Delegates to the `Slugify` feature, so to change slug
* logic decorate `Slugify` alone rather than the whole string formatter.
*/
slugify(value: string): string;
}

export const StringFormatter = createAbstraction<IStringFormatter>("StringFormatter");

export namespace StringFormatter {
export type Interface = IStringFormatter;
}
18 changes: 18 additions & 0 deletions packages/app-admin/src/features/stringFormatter/feature.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
import { createFeature } from "@webiny/feature/admin";
import { SlugifyFeature } from "~/features/slugify/feature.js";
import { StringFormatter } from "./abstractions.js";
import { DefaultStringFormatter } from "./DefaultStringFormatter.js";

export const StringFormatterFeature = createFeature({
name: "StringFormatter",
register(container) {
// Register the transforms the formatter delegates to, then the formatter itself.
SlugifyFeature.register(container);
container.register(DefaultStringFormatter).inSingletonScope();
},
resolve(container) {
return {
stringFormatter: container.resolve(StringFormatter)
};
}
});
4 changes: 4 additions & 0 deletions packages/app-admin/src/features/stringFormatter/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
export * from "./abstractions.js";
export * from "./DefaultStringFormatter.js";
export * from "./feature.js";
export * from "./useStringFormatter.js";
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
import { useFeature } from "@webiny/app";
import { StringFormatterFeature } from "./feature.js";

/**
* Resolves the shared StringFormatter for components that have no presenter of their own. Prefer
* formatting in a presenter where one exists.
*/
export function useStringFormatter() {
return useFeature(StringFormatterFeature).stringFormatter;
}
10 changes: 10 additions & 0 deletions packages/app-admin/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -64,6 +64,16 @@ export { ToolsFeature } from "./features/tools/feature.js";
export { Tool, ToolRegistry, ToolPipelineRunner } from "./features/tools/abstractions.js";
export type { ITool, IToolRegistry, IToolPipelineRunner } from "./features/tools/abstractions.js";

export { StringFormatter } from "./features/stringFormatter/abstractions.js";
export type { IStringFormatter } from "./features/stringFormatter/abstractions.js";
export { StringFormatterFeature } from "./features/stringFormatter/feature.js";
export { useStringFormatter } from "./features/stringFormatter/useStringFormatter.js";

// Fine-grained, decoratable transform behind StringFormatter.slugify(). Decorate this to change slug
// logic without touching the rest of the string formatter.
export { Slugify } from "./features/slugify/abstractions.js";
export type { ISlugify } from "./features/slugify/abstractions.js";

// Hooks
export * from "./hooks/index.js";
export { useWcp } from "./presentation/wcp/useWcp.js";
Expand Down
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { makeAutoObservable, runInAction, computed } from "mobx";
import slugify from "slugify";
import { StringFormatter } from "~/features/stringFormatter/abstractions.js";
import { ListPresenter } from "~/presentation/listPresenter/abstractions.js";
import { FormModelFactory } from "~/features/formModel/abstractions.js";
import type { IFormModel } from "~/features/formModel/abstractions.js";
Expand Down Expand Up @@ -30,7 +30,8 @@ class ApiKeysPresenterImpl implements Abstraction.Interface {
private createApiKeyUseCase: CreateApiKeyUseCase.Interface,
private updateApiKeyUseCase: UpdateApiKeyUseCase.Interface,
private deleteApiKeyUseCase: DeleteApiKeyUseCase.Interface,
private cache: ApiKeysListCache.Interface
private cache: ApiKeysListCache.Interface,
private stringFormatter: StringFormatter.Interface
) {
this._form = this.buildForm(true, "new");
makeAutoObservable<
Expand All @@ -42,6 +43,7 @@ class ApiKeysPresenterImpl implements Abstraction.Interface {
| "updateApiKeyUseCase"
| "deleteApiKeyUseCase"
| "cache"
| "stringFormatter"
>(this, {
formModelFactory: false,
listApiKeysUseCase: false,
Expand All @@ -50,6 +52,7 @@ class ApiKeysPresenterImpl implements Abstraction.Interface {
updateApiKeyUseCase: false,
deleteApiKeyUseCase: false,
cache: false,
stringFormatter: false,
vm: computed
});
}
Expand Down Expand Up @@ -189,14 +192,7 @@ class ApiKeysPresenterImpl implements Abstraction.Interface {
if (slugValue || !value) {
return;
}
form.field("slug").setValue(
slugify(String(value), {
replacement: "-",
lower: true,
remove: /[*#?<>_{}[\]+~.()'"!:;@]/g,
trim: false
})
);
form.field("slug").setValue(this.stringFormatter.slugify(String(value)));
}),
slug: fields.text().label("Slug").required("Slug is required.").disabled(!isNew),
description: fields
Expand Down Expand Up @@ -231,6 +227,7 @@ export const ApiKeysPresenter = Abstraction.createImplementation({
CreateApiKeyUseCase,
UpdateApiKeyUseCase,
DeleteApiKeyUseCase,
ApiKeysListCache
ApiKeysListCache,
StringFormatter
]
});
Loading