Skip to content
Merged
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
13 changes: 13 additions & 0 deletions .changeset/11865-j-designer-forms-shared-select.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
---
'@object-ui/plugin-designer': patch
---

The designer forms pick with the shared `Select`, the control the rest of the console picks with (objectui#11865, the designer forms' part of that card): `FieldDesigner`'s type filter, `DataModelDesigner`'s field-type picker on each field row, `BrandingEditor`'s font family and `AppCreationWizard`'s template.

The four pickers were browser-native selects, so they looked and behaved differently from the console's other dropdowns. They now use the shared Radix `Select`: the same trigger, dropdown and keyboard behaviour. The type filter keeps its category headings (Text, Number, Date & Time, Choice, Relation, Advanced) as groups of the dropdown.

What they write is unchanged. Each option gives the form the same value as before: "All Types" still clears the type filter, "Default (System)" still clears the branding's `fontFamily`, and "None" still sets the draft's `template` to an empty string. Re-picking the current option writes nothing. The font family and template pickers keep the accessible name their label gave the native select ("Font Family", "Template") and show read-only mode as disabled. The type filter stays enabled in read-only mode, as before, and a read-only data model still shows each field's type as text. On a data model entity card, a click on the type picker still does not select the card, and Delete or Escape on it still does not act on the canvas.

One display change: a value none of a picker's options carries now shows as itself. The native select showed its first option instead (`text` for a field type, "Default (System)" for a font, "None" for a template), which is not what the form holds.

**Clause-②: no.** No published face moves: the package entry exports the same names, the four components take the same props, and no i18n key is added. What moves is the four controls' own markup, described above.
82 changes: 71 additions & 11 deletions packages/plugin-designer/src/AppCreationWizard.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,7 @@ import {
Globe,
X,
} from 'lucide-react';
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@object-ui/components';
import { clsx } from 'clsx';
import { twMerge } from 'tailwind-merge';
import { resolveNavItemLabel, type NavTargetLabelResolver } from '@object-ui/layout';
Expand Down Expand Up @@ -280,6 +281,69 @@ function StepIndicator({ steps, currentIndex, onStepClick }: StepIndicatorProps)
// Step 1: Basic Info
// ============================================================================

/** The item a template none of the options carries is shown by. */
const OUTSIDE_OPTIONS = 'outside';

/**
* objectui#11865 — the basic step's template picker, drawn with the shared
* `Select`, the control the rest of the designer picks with. It used to be a
* browser-native `<select>`. What a pick writes is unchanged: `onPick`
* receives the picked option's own value, the string the native control's
* `change` carried (`''` for "None", else the template's id). Re-picking the
* current option writes nothing, as it did there.
*
* - Items carry their option's INDEX, not its value: "None" is the option
* whose value is `''`, which `SelectItem` refuses.
* - A template none of the options carries gets an item of its own, labelled
* with its id, so the trigger shows what the draft holds. The native control
* showed "None" there. Picking that item writes nothing.
* - `disabled` reaches the trigger through the primitive, as it reached the
* native control.
* - `id` goes on the trigger, so the `<label htmlFor>` names it as it named the
* native control.
*/
function TemplatePicker({
id,
value,
options,
onPick,
disabled,
}: {
id: string;
value: string;
options: ReadonlyArray<{ value: string; label: string }>;
onPick: (value: string) => void;
disabled: boolean;
}) {
const at = options.findIndex((o) => o.value === value);
return (
<Select
value={at !== -1 ? String(at) : OUTSIDE_OPTIONS}
onValueChange={(token) => {
// `undefined` for the outside item: it is the draft's own template, so there is nothing to write.
const picked = options[Number(token)];
if (picked) onPick(picked.value);
}}
disabled={disabled}
>
<SelectTrigger
id={id}
className="h-auto rounded-md border-gray-300 px-3 py-2 text-sm shadow-sm focus:border-blue-500 focus:ring-1 focus:ring-blue-500 focus:ring-offset-0 disabled:bg-gray-50"
>
<SelectValue />
</SelectTrigger>
<SelectContent>
{at === -1 && <SelectItem value={OUTSIDE_OPTIONS}>{value}</SelectItem>}
{options.map((o, i) => (
<SelectItem key={`${i}:${o.value}`} value={String(i)}>
{o.label}
</SelectItem>
))}
</SelectContent>
</Select>
);
}

interface BasicInfoStepProps {
draft: AppWizardDraft;
templates: Array<{ id: string; label: string; description?: string }>;
Expand Down Expand Up @@ -385,20 +449,16 @@ function BasicInfoStep({ draft, templates, readOnly, onChange, t }: BasicInfoSte
<label htmlFor="app-template" className="block text-sm font-medium text-gray-700">
{t('appDesigner.template')}
</label>
<select
<TemplatePicker
id="app-template"
value={draft.template ?? ''}
onChange={(e) => onChange({ template: e.target.value })}
options={[
{ value: '', label: 'None' },
...templates.map((template) => ({ value: template.id, label: template.label })),
]}
onPick={(template) => onChange({ template })}
disabled={readOnly}
className="block w-full rounded-md border border-gray-300 px-3 py-2 text-sm shadow-sm outline-none transition-colors focus:border-blue-500 focus:ring-1 focus:ring-blue-500 disabled:cursor-not-allowed disabled:bg-gray-50"
>
<option value="">None</option>
{templates.map((template) => (
<option key={template.id} value={template.id}>
{template.label}
</option>
))}
</select>
/>
</div>
)}
</div>
Expand Down
84 changes: 72 additions & 12 deletions packages/plugin-designer/src/BrandingEditor.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@ import {
Upload,
Globe,
} from 'lucide-react';
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@object-ui/components';
import { clsx } from 'clsx';
import { twMerge } from 'tailwind-merge';
import { useDesignerTranslation } from './hooks/useDesignerTranslation';
Expand Down Expand Up @@ -98,6 +99,70 @@ const FONT_FAMILIES = [
'system-ui',
];

/** The item a font none of the options carries is shown by. */
const OUTSIDE_OPTIONS = 'outside';

/**
* objectui#11865 — the font family picker, drawn with the shared `Select`, the
* control the rest of the designer picks with. It used to be a browser-native
* `<select>`. What a pick writes is unchanged: `onPick` receives the picked
* option's own value, the string the native control's `change` carried, and
* the caller turns it into the same `fontFamily` as before. Re-picking the
* current option writes nothing, as it did there.
*
* - Items carry their option's INDEX, not its value: "Default (System)" is the
* option whose value is `''`, which `SelectItem` refuses.
* - A font none of the options carries gets an item of its own, labelled with
* the font, so the trigger shows what the branding holds. The native control
* showed "Default (System)" there. Picking that item writes nothing.
* - `disabled` reaches the trigger through the primitive, as it reached the
* native control.
* - `id` goes on the trigger, so the `<label htmlFor>` names it as it named the
* native control.
*/
function FontPicker({
id,
value,
options,
onPick,
disabled,
}: {
id: string;
value: string;
options: ReadonlyArray<{ value: string; label: string }>;
onPick: (value: string) => void;
disabled: boolean;
}) {
const at = options.findIndex((o) => o.value === value);
return (
<Select
value={at !== -1 ? String(at) : OUTSIDE_OPTIONS}
onValueChange={(token) => {
// `undefined` for the outside item: it is the branding's own font, so there is nothing to write.
const picked = options[Number(token)];
if (picked) onPick(picked.value);
}}
disabled={disabled}
>
<SelectTrigger
id={id}
data-testid="branding-font-select"
className="h-auto rounded-md border-gray-300 py-2 pl-9 pr-3 text-sm shadow-sm focus:border-blue-500 focus:ring-1 focus:ring-blue-500 focus:ring-offset-0 disabled:bg-gray-50"
>
<SelectValue />
</SelectTrigger>
<SelectContent>
{at === -1 && <SelectItem value={OUTSIDE_OPTIONS}>{value}</SelectItem>}
{options.map((o, i) => (
<SelectItem key={`${i}:${o.value}`} value={String(i)}>
{o.label}
</SelectItem>
))}
</SelectContent>
</Select>
);
}

// ============================================================================
// BrandingEditor Component
// ============================================================================
Expand Down Expand Up @@ -398,21 +463,16 @@ export function BrandingEditor({
</label>
<div className="relative">
<Type className="absolute left-2.5 top-2.5 h-4 w-4 text-gray-400" />
<select
<FontPicker
id="be-font"
data-testid="branding-font-select"
value={branding.fontFamily ?? ''}
onChange={(e) => updateBranding({ fontFamily: e.target.value || undefined })}
options={[
{ value: '', label: t('appDesigner.fontDefault') },
...FONT_FAMILIES.map((font) => ({ value: font, label: font })),
]}
onPick={(font) => updateBranding({ fontFamily: font || undefined })}
disabled={readOnly}
className="block w-full rounded-md border border-gray-300 py-2 pl-9 pr-3 text-sm shadow-sm outline-none transition-colors focus:border-blue-500 focus:ring-1 focus:ring-blue-500 disabled:cursor-not-allowed disabled:bg-gray-50 appearance-none"
>
<option value="">{t('appDesigner.fontDefault')}</option>
{FONT_FAMILIES.map((font) => (
<option key={font} value={font}>
{font}
</option>
))}
</select>
/>
</div>
</div>
</div>
Expand Down
86 changes: 72 additions & 14 deletions packages/plugin-designer/src/DataModelDesigner.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@
import React, { useState, useCallback, useEffect, useRef } from 'react';
import type { DataModelEntity, DataModelField, DataModelRelationship, DesignerCanvasConfig } from '@object-ui/types';
import { Database, Plus, Trash2, Link2, Undo2, Redo2, Grid3X3, ZoomIn, ZoomOut, RotateCcw, ChevronDown, ChevronRight, Copy, Clipboard, Users } from 'lucide-react';
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@object-ui/components';
import { clsx } from 'clsx';
import { twMerge } from 'tailwind-merge';
import { useDesignerHistory } from './hooks/useDesignerHistory';
Expand All @@ -34,6 +35,68 @@ const DATA_MODEL_FIELD_TYPES = [
'multiselect', 'lookup', 'attachment', 'formula', 'autonumber',
] as const;

/** The item a type none of the options carries is shown by. */
const OUTSIDE_OPTIONS = 'outside';

/** Stops a click inside the type picker from reaching the entity card, which selects on click. */
const keepClickInPicker = (e: React.MouseEvent) => e.stopPropagation();

/**
* objectui#11865 — a field row's type picker on an entity card, drawn with the
* shared `Select`, the control the rest of the designer picks with. It used to
* be a browser-native `<select>`. What a pick writes is unchanged: `onPick`
* receives the picked type, the string the native control's `change` carried,
* and re-picking the current type writes nothing, as it did there.
*
* - Items carry their option's INDEX, not the type, as the shared pickers do.
* - A type none of the options carries gets an item of its own, labelled with
* the type, so the trigger shows what the field holds. The native control
* showed its first option (`text`) there. Picking that item writes nothing.
* - A click on the trigger or in the open list does not reach the card: the
* native control stopped its own click, and its list sent the page none. The
* list renders in a portal, but React still bubbles its events through the
* card.
* - Read-only has no picker: the row shows the type as text instead.
*/
function FieldTypePicker({
value,
onPick,
testId,
}: {
value: string;
onPick: (type: string) => void;
testId: string;
}) {
const at = DATA_MODEL_FIELD_TYPES.findIndex((type) => type === value);
const outside = at === -1 && value !== '';
return (
<Select
value={at !== -1 ? String(at) : outside ? OUTSIDE_OPTIONS : ''}
onValueChange={(token) => {
// `undefined` for the outside item: it is the field's own type, so there is nothing to write.
const picked = DATA_MODEL_FIELD_TYPES[Number(token)];
if (picked) onPick(picked);
}}
>
<SelectTrigger
data-testid={testId}
onClick={keepClickInPicker}
className="ml-auto h-auto w-auto cursor-pointer gap-0.5 rounded border-none bg-transparent p-0 text-xs text-muted-foreground focus:ring-1 focus:ring-primary focus:ring-offset-0"
>
<SelectValue />
</SelectTrigger>
<SelectContent onClick={keepClickInPicker}>
{outside && <SelectItem value={OUTSIDE_OPTIONS}>{value}</SelectItem>}
{DATA_MODEL_FIELD_TYPES.map((type, i) => (
<SelectItem key={type} value={String(i)}>
{type}
</SelectItem>
))}
</SelectContent>
</Select>
);
}

/** Arrange entities in a grid layout */
function calculateGridAutoLayout(
entities: DataModelEntity[],
Expand Down Expand Up @@ -578,8 +641,12 @@ export function DataModelDesigner({
const el = containerRef.current;
if (!el) return;
const handleKeyDown = (e: KeyboardEvent) => {
const tag = (e.target as HTMLElement).tagName;
const target = e.target as HTMLElement;
const tag = target.tagName;
if (tag === 'INPUT' || tag === 'TEXTAREA' || tag === 'SELECT') return;
// A field row's type picker (objectui#11865) is a `<button role="combobox">`,
// not a `SELECT`: its keys are its own, as they were the native control's.
if (target.getAttribute('role') === 'combobox') return;

const isCtrl = e.ctrlKey || e.metaKey;

Expand Down Expand Up @@ -960,20 +1027,11 @@ export function DataModelDesigner({
</span>
)}
{!readOnly ? (
<select
<FieldTypePicker
value={field.type}
onChange={(e) => {
e.stopPropagation();
handleFieldTypeChange(entity.id, fieldIndex, e.target.value);
}}
onClick={(e) => e.stopPropagation()}
className="text-xs text-muted-foreground ml-auto bg-transparent border-none focus:ring-1 focus:ring-primary rounded cursor-pointer p-0"
data-testid={`field-type-${entity.id}-${fieldIndex}`}
>
{DATA_MODEL_FIELD_TYPES.map((t) => (
<option key={t} value={t}>{t}</option>
))}
</select>
onPick={(type) => handleFieldTypeChange(entity.id, fieldIndex, type)}
testId={`field-type-${entity.id}-${fieldIndex}`}
/>
) : (
<span className="text-muted-foreground ml-auto">{field.type}</span>
)}
Expand Down
Loading
Loading