Skip to content

Commit 0ba6744

Browse files
authored
feat: Support inline file category creation (#864)
1 parent eec498b commit 0ba6744

12 files changed

Lines changed: 465 additions & 93 deletions

File tree

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
---
2+
'@baseplate-dev/project-builder-lib': patch
3+
'@baseplate-dev/project-builder-web': patch
4+
'@baseplate-dev/plugin-storage': patch
5+
---
6+
7+
Support inline file category creation and editing from the file transformer form, eliminating the need to navigate to the plugin config page.

packages/project-builder-lib/src/web/specs/model-transformer-web-spec.ts

Lines changed: 20 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -6,14 +6,22 @@ import type { ModelConfigInput, TransformerConfig } from '#src/schema/index.js';
66

77
import { createFieldMapSpec } from '#src/plugins/utils/create-field-map-spec.js';
88

9-
export interface ModelTransformerWebFormProps {
9+
export interface ModelTransformerWebFormFieldsProps {
1010
// oxlint-disable-next-line typescript/no-explicit-any
1111
formProps: UseFormReturn<any>;
1212
name: string;
1313
originalModel: ModelConfigInput;
1414
pluginKey: string | undefined;
1515
}
1616

17+
export interface ModelTransformerWebFullFormProps {
18+
transformer?: TransformerConfig;
19+
onUpdate: (transformer: TransformerConfig) => void;
20+
isCreate: boolean;
21+
originalModel: ModelConfigInput;
22+
pluginKey: string | undefined;
23+
}
24+
1725
export interface ModelTransformerWebConfig<
1826
T extends TransformerConfig = TransformerConfig,
1927
> {
@@ -22,7 +30,17 @@ export interface ModelTransformerWebConfig<
2230
label: string;
2331
description: string;
2432
instructions?: string;
25-
Form?: React.ComponentType<ModelTransformerWebFormProps>;
33+
/**
34+
* Full form component that owns the entire form element, submit logic, and footer.
35+
* Mutually exclusive with `FormFields`.
36+
*/
37+
Form?: React.ComponentType<ModelTransformerWebFullFormProps>;
38+
/**
39+
* Form fields component that provides only the fields.
40+
* The framework wraps it in a `<form>` element with dialog footer.
41+
* Mutually exclusive with `Form`.
42+
*/
43+
FormFields?: React.ComponentType<ModelTransformerWebFormFieldsProps>;
2644
allowNewTransformer?: (
2745
projectContainer: ProjectDefinitionContainer,
2846
modelConfig: ModelConfigInput,

packages/project-builder-web/src/routes/data/models/edit.$key/-components/service/service-embedded-relation-form.tsx

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
import type { EmbeddedRelationTransformerConfig } from '@baseplate-dev/project-builder-lib';
22
import type {
33
ModelTransformerWebConfig,
4-
ModelTransformerWebFormProps,
4+
ModelTransformerWebFormFieldsProps,
55
} from '@baseplate-dev/project-builder-lib/web';
66
import type React from 'react';
77
import type { UseFormReturn } from 'react-hook-form';
@@ -26,7 +26,7 @@ import { useOriginalModel } from '../../../-hooks/use-original-model.js';
2626
function ServiceEmbeddedRelationForm({
2727
formProps,
2828
name,
29-
}: ModelTransformerWebFormProps): React.JSX.Element {
29+
}: ModelTransformerWebFormFieldsProps): React.JSX.Element {
3030
// force type cast to avoid TS error
3131
const prefix = name as 'prefix';
3232
const formPropsTyped = formProps as unknown as UseFormReturn<{
@@ -185,6 +185,6 @@ export const embeddedRelationTransformerWebConfig: ModelTransformerWebConfig<Emb
185185
embeddedFieldNames: [],
186186
modelRef: '',
187187
}),
188-
Form: ServiceEmbeddedRelationForm,
188+
FormFields: ServiceEmbeddedRelationForm,
189189
pluginKey: undefined,
190190
};

packages/project-builder-web/src/routes/data/models/edit.$key/-components/service/service-transformer-form.tsx

Lines changed: 41 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -30,12 +30,50 @@ interface ServiceTransformerFormProps {
3030

3131
export function ServiceTransformerForm({
3232
className,
33-
webConfig: { Form, pluginKey },
33+
webConfig,
3434
transformer,
3535
onUpdate,
3636
isCreate,
3737
}: ServiceTransformerFormProps): React.JSX.Element | null {
3838
const originalModel = useOriginalModel();
39+
40+
// Full form mode: delegate entirely to the Form component
41+
if (webConfig.Form) {
42+
const { Form } = webConfig;
43+
return (
44+
<Form
45+
transformer={transformer}
46+
onUpdate={onUpdate}
47+
isCreate={isCreate}
48+
originalModel={originalModel}
49+
pluginKey={webConfig.pluginKey}
50+
/>
51+
);
52+
}
53+
54+
// FormFields mode: wrap fields in a form with dialog footer
55+
return (
56+
<ServiceTransformerFormFields
57+
className={className}
58+
webConfig={webConfig}
59+
transformer={transformer}
60+
onUpdate={onUpdate}
61+
isCreate={isCreate}
62+
originalModel={originalModel}
63+
/>
64+
);
65+
}
66+
67+
function ServiceTransformerFormFields({
68+
className,
69+
webConfig: { FormFields, pluginKey },
70+
transformer,
71+
onUpdate,
72+
isCreate,
73+
originalModel,
74+
}: ServiceTransformerFormProps & {
75+
originalModel: ReturnType<typeof useOriginalModel>;
76+
}): React.JSX.Element | null {
3977
const transformerSchema = useDefinitionSchema(createTransformerSchema);
4078
const schema = useMemo(
4179
() =>
@@ -64,7 +102,7 @@ export function ServiceTransformerForm({
64102

65103
const formId = useId();
66104

67-
if (!Form) {
105+
if (!FormFields) {
68106
return null;
69107
}
70108

@@ -77,7 +115,7 @@ export function ServiceTransformerForm({
77115
return onSubmit(e);
78116
}}
79117
>
80-
<Form
118+
<FormFields
81119
formProps={formProps}
82120
name="transformer"
83121
originalModel={originalModel}

packages/project-builder-web/src/routes/data/models/edit.$key/-components/service/service-transformers-section.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -72,7 +72,7 @@ function ServiceTransformerRecord({
7272
))}
7373
</RecordViewItemList>
7474
<RecordViewActions>
75-
{transformerConfig.Form && (
75+
{(transformerConfig.Form ?? transformerConfig.FormFields) && (
7676
<ServiceTransformerDialog
7777
webConfig={transformerConfig}
7878
transformer={field}

packages/ui-components/src/components/ui/record-view/record-view.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -68,7 +68,7 @@ function RecordViewActions({
6868
children,
6969
}: RecordViewActionsProps): React.ReactElement {
7070
return (
71-
<div className={cn('flex items-center gap-2', className)}>{children}</div>
71+
<div className={cn('flex items-center gap-4', className)}>{children}</div>
7272
);
7373
}
7474

plugins/plugin-storage/package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -54,6 +54,7 @@
5454
"@baseplate-dev/sync": "workspace:*",
5555
"@baseplate-dev/ui-components": "workspace:*",
5656
"@baseplate-dev/utils": "workspace:*",
57+
"@hookform/lenses": "0.8.1",
5758
"@hookform/resolvers": "5.2.2",
5859
"es-toolkit": "1.44.0",
5960
"react": "catalog:",

plugins/plugin-storage/src/storage/core/components/file-category-dialog.tsx

Lines changed: 10 additions & 53 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,6 @@
11
import type React from 'react';
22
import type { Control } from 'react-hook-form';
33

4-
import { authConfigSpec } from '@baseplate-dev/project-builder-lib';
54
import {
65
useDefinitionSchema,
76
useProjectDefinition,
@@ -14,11 +13,8 @@ import {
1413
DialogFooter,
1514
DialogHeader,
1615
DialogTitle,
17-
InputFieldController,
18-
MultiComboboxFieldController,
19-
SelectFieldController,
20-
SwitchFieldController,
2116
} from '@baseplate-dev/ui-components';
17+
import { useLens } from '@hookform/lenses';
2218
import { zodResolver } from '@hookform/resolvers/zod';
2319
import { useId } from 'react';
2420
import { useForm, useWatch } from 'react-hook-form';
@@ -29,6 +25,8 @@ import type {
2925
} from '../schema/plugin-definition.js';
3026

3127
import { createFileCategorySchema } from '../schema/plugin-definition.js';
28+
import { FileCategoryFormFields } from './file-category-form-fields.js';
29+
import { getSelectableRoleOptions } from './get-selectable-role-options.js';
3230

3331
interface FileCategoryDialogProps {
3432
open?: boolean;
@@ -56,6 +54,7 @@ export function FileCategoryDialog({
5654
});
5755

5856
const { control, handleSubmit } = form;
57+
const lens = useLens({ control });
5958

6059
const onSubmit = handleSubmit((data) => {
6160
onSave(data);
@@ -64,14 +63,7 @@ export function FileCategoryDialog({
6463

6564
const formId = useId();
6665

67-
// Get available auth roles
68-
const roleOptions = pluginContainer
69-
.use(authConfigSpec)
70-
.getAuthConfigOrThrow(definition)
71-
.roles.map((role) => ({
72-
label: role.name,
73-
value: role.id,
74-
}));
66+
const roleOptions = getSelectableRoleOptions(pluginContainer, definition);
7567

7668
// Get available storage adapters from parent form
7769
const adapters = useWatch({ control: parentControl, name: 's3Adapters' });
@@ -100,46 +92,11 @@ export function FileCategoryDialog({
10092
: 'Update the file category details below.'}
10193
</DialogDescription>
10294
</DialogHeader>
103-
<div className="storage:space-y-4 storage:py-4">
104-
<InputFieldController
105-
label="Category Name"
106-
name="name"
107-
control={control}
108-
placeholder="e.g., USER_PROFILE_AVATAR"
109-
description="Must be CONSTANT_CASE format"
110-
/>
111-
<InputFieldController
112-
label="Max File Size (MB)"
113-
name="maxFileSizeMb"
114-
control={control}
115-
type="number"
116-
placeholder="e.g., 10"
117-
description="Maximum file size in megabytes"
118-
registerOptions={{
119-
valueAsNumber: true,
120-
}}
121-
/>
122-
<MultiComboboxFieldController
123-
label="Upload Roles"
124-
name="authorize.uploadRoles"
125-
control={control}
126-
options={roleOptions}
127-
placeholder="Select roles that can upload..."
128-
description="User roles authorized to upload files"
129-
/>
130-
<SelectFieldController
131-
label="Storage Adapter"
132-
name="adapterRef"
133-
control={control}
134-
options={adapterOptions}
135-
placeholder="Select storage adapter..."
136-
description="Where files will be stored"
137-
/>
138-
<SwitchFieldController
139-
label="Disable Auto-Cleanup"
140-
name="disableAutoCleanup"
141-
control={control}
142-
description="When enabled, files in this category will not be automatically cleaned up"
95+
<div className="storage:py-4">
96+
<FileCategoryFormFields
97+
lens={lens}
98+
roleOptions={roleOptions}
99+
adapterOptions={adapterOptions}
143100
/>
144101
</div>
145102
<DialogFooter>
Lines changed: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,62 @@
1+
import type { Lens } from '@hookform/lenses';
2+
3+
import {
4+
InputFieldController,
5+
MultiComboboxFieldController,
6+
SelectFieldController,
7+
SwitchFieldController,
8+
} from '@baseplate-dev/ui-components';
9+
10+
import type { FileCategoryInput } from '../schema/plugin-definition.js';
11+
12+
interface FileCategoryFormFieldsProps {
13+
lens: Lens<FileCategoryInput>;
14+
roleOptions: { label: string; value: string }[];
15+
adapterOptions: { label: string; value: string }[];
16+
}
17+
18+
export function FileCategoryFormFields({
19+
lens,
20+
roleOptions,
21+
adapterOptions,
22+
}: FileCategoryFormFieldsProps): React.JSX.Element {
23+
return (
24+
<div className="storage:space-y-4">
25+
<InputFieldController
26+
{...lens.focus('name').interop()}
27+
label="Category Name"
28+
placeholder="e.g., USER_PROFILE_AVATAR"
29+
description="Must be CONSTANT_CASE format"
30+
/>
31+
<InputFieldController
32+
{...lens.focus('maxFileSizeMb').interop()}
33+
label="Max File Size (MB)"
34+
type="number"
35+
placeholder="e.g., 10"
36+
description="Maximum file size in megabytes"
37+
registerOptions={{
38+
valueAsNumber: true,
39+
}}
40+
/>
41+
<MultiComboboxFieldController
42+
{...lens.focus('authorize.uploadRoles').interop()}
43+
label="Upload Roles"
44+
options={roleOptions}
45+
placeholder="Select roles that can upload..."
46+
description="User roles authorized to upload files"
47+
/>
48+
<SelectFieldController
49+
{...lens.focus('adapterRef').interop()}
50+
label="Storage Adapter"
51+
options={adapterOptions}
52+
placeholder="Select storage adapter..."
53+
description="Where files will be stored"
54+
/>
55+
<SwitchFieldController
56+
{...lens.focus('disableAutoCleanup').interop()}
57+
label="Disable Auto-Cleanup"
58+
description="When enabled, files in this category will not be automatically cleaned up"
59+
/>
60+
</div>
61+
);
62+
}
Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
import type { ProjectDefinition } from '@baseplate-dev/project-builder-lib';
2+
import type { UseProjectDefinitionResult } from '@baseplate-dev/project-builder-lib/web';
3+
4+
import { authConfigSpec } from '@baseplate-dev/project-builder-lib';
5+
6+
/**
7+
* Returns role options suitable for selection in storage forms.
8+
* Excludes auto-assigned roles (e.g. system, public) but keeps 'user'
9+
* since it's commonly needed for upload authorization.
10+
*/
11+
export function getSelectableRoleOptions(
12+
pluginContainer: UseProjectDefinitionResult['pluginContainer'],
13+
definition: ProjectDefinition,
14+
): { label: string; value: string }[] {
15+
return pluginContainer
16+
.use(authConfigSpec)
17+
.getAuthConfigOrThrow(definition)
18+
.roles.filter((role) => !role.autoAssigned || role.name === 'user')
19+
.map((role) => ({
20+
label: role.name,
21+
value: role.id,
22+
}));
23+
}

0 commit comments

Comments
 (0)