Repository navigation
Implement component reordering via drag-and-drop in designer canvas - #5
Conversation
…mponents Co-authored-by: huangyiirene <7665279+huangyiirene@users.noreply.github.com>
Co-authored-by: huangyiirene <7665279+huangyiirene@users.noreply.github.com>
…DO comment Co-authored-by: huangyiirene <7665279+huangyiirene@users.noreply.github.com>
Co-authored-by: huangyiirene <7665279+huangyiirene@users.noreply.github.com>
There was a problem hiding this comment.
Pull request overview
This PR implements drag-and-drop functionality for reordering components within the designer canvas. Previously, components could only be added from the palette but not rearranged after placement.
Changes:
- Added state management for tracking dragged nodes and a
moveNode()function with circular reference validation - Enabled draggable behavior for canvas components with visual feedback (grab cursor, opacity change, dashed outline)
- Created comprehensive unit tests for the new drag-and-drop operations
Reviewed changes
Copilot reviewed 5 out of 5 changed files in this pull request and generated 2 comments.
Show a summary per file
| File | Description |
|---|---|
| packages/designer/src/context/DesignerContext.tsx | Adds draggingNodeId state, moveNode() function, and helper functions findNodeById() and moveNodeInTree() to support component reordering |
| packages/designer/src/components/Canvas.tsx | Implements draggable behavior for canvas components, drop handling for both palette and canvas drags, and visual feedback styles |
| packages/designer/src/tests/drag-and-drop.test.tsx | Adds unit tests for dragging state management and node movement operations |
| packages/designer/README.md | Updates feature list and roadmap to reflect completed drag-and-drop functionality |
| packages/designer/IMPLEMENTATION.zh-CN.md | Updates Chinese implementation documentation with completed features and known limitations |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| React.useEffect(() => { | ||
| if (!canvasRef.current) return; | ||
|
|
||
| const handleDragStart = (e: DragEvent) => { | ||
| const target = (e.target as Element).closest('[data-obj-id]'); | ||
| if (target && target.getAttribute('data-obj-id')) { | ||
| const nodeId = target.getAttribute('data-obj-id'); | ||
| // Don't allow dragging the root node | ||
| if (nodeId === schema.id) { | ||
| e.preventDefault(); | ||
| return; | ||
| } | ||
| setDraggingNodeId(nodeId); | ||
| e.stopPropagation(); | ||
| if (e.dataTransfer) { | ||
| e.dataTransfer.effectAllowed = 'move'; | ||
| e.dataTransfer.setData('text/plain', nodeId || ''); | ||
| } | ||
| } | ||
| }; | ||
|
|
||
| const handleDragEnd = () => { | ||
| setDraggingNodeId(null); | ||
| }; | ||
|
|
||
| // Add draggable attribute and event listeners to all elements with data-obj-id within canvas | ||
| const elements = canvasRef.current.querySelectorAll('[data-obj-id]'); | ||
| elements.forEach(el => { | ||
| // Don't make root draggable | ||
| if (el.getAttribute('data-obj-id') !== schema.id) { | ||
| el.setAttribute('draggable', 'true'); | ||
| el.addEventListener('dragstart', handleDragStart as EventListener); | ||
| el.addEventListener('dragend', handleDragEnd as EventListener); | ||
| } | ||
| }); | ||
|
|
||
| return () => { | ||
| elements.forEach(el => { | ||
| el.removeEventListener('dragstart', handleDragStart as EventListener); | ||
| el.removeEventListener('dragend', handleDragEnd as EventListener); | ||
| }); | ||
| }; | ||
| }, [schema, setDraggingNodeId]); |
There was a problem hiding this comment.
The useEffect hook re-runs whenever the schema changes, which could be frequent during editing. This causes all drag event listeners to be removed and re-attached on every schema update, which is inefficient. Consider using a MutationObserver to dynamically handle newly added elements, or add schema.id to the dependency array instead of the entire schema object to reduce unnecessary re-renders.
| e.stopPropagation(); | ||
| if (e.dataTransfer) { | ||
| e.dataTransfer.effectAllowed = 'move'; | ||
| e.dataTransfer.setData('text/plain', nodeId || ''); |
There was a problem hiding this comment.
The fallback to empty string when nodeId is null/undefined is unnecessary since nodeId has already been null-checked at line 108. The || '' can be removed for cleaner code.
| e.dataTransfer.setData('text/plain', nodeId || ''); | |
| e.dataTransfer.setData('text/plain', nodeId); |
|
✅ All checks passed!
|
- Fix handleExportCSV to guard on gridRef.current?.api (issue #1) - Add dedicated onContextMenuAction callback instead of overloading onCellClicked (issue #2) - Remove icon property from customItems to prevent HTML injection (issue #3) - Remove validation claim from README - only basic AG Grid editing (issue #4) - Add test assertions for all new inputs (editable, exportConfig, etc.) (issue #5) - Fix onExport type to only support 'csv' format (issue #6) - Remove unused ColumnConfig properties (autoSize, groupable) (issue #9) - Type schema props with proper interfaces instead of 'any' (issue #10) - Update export description to only mention CSV (issue #11) - Add AG Grid Community vs Enterprise section to docs (issue #8) - Update README and docs with new callback and clarifications All tests pass (8/8), lint clean (0 errors) Co-authored-by: hotlong <50353452+hotlong@users.noreply.github.com>
…warning, i18n fallback - Issue #1: Normalize `in`/`not in` operators to backend-compatible `or`/`and` of `=`/`!=` - Issue #2: Filter merging now validates and filters empty conditions - Issue #3: CSV export safely serializes arrays (semicolon-separated) and objects (JSON) - Issue #5: Request counter prevents stale data from overwriting latest results - Issue #6: PullToRefresh resets pull distance immediately to prevent UI lock - Issue #7: $top configurable via schema.pagination, data limit warning shown - Issue #8: Extended i18n fallback translations for all ListView labels - Issue #9: Defensive null checks in effectiveFields for mismatched objectDef - Issue #10: Added FilterNormalization, Export, and DataFetch test suites Co-authored-by: hotlong <50353452+hotlong@users.noreply.github.com>
`skills/objectui/SKILL.md` is the top-level file of the published skill — the
one an agent loads before any guide — and line 81 prescribed `cols: { sm: 1,
md: 2, lg: 4 }`. Nothing reads that key, and `sm` is not the base breakpoint.
Measured against the schema and the renderer, not against the sibling docs:
- `GridSchema` (`packages/types/src/layout.ts:202-209`) declares `columns`.
- `grid.tsx:57-67` reads `columns.xs / .sm / .md / .lg / .xl` and treats `xs`
as the base (`baseCols = schema.columns.xs ?? 1`).
- Rendering the dead sample emits `grid grid-cols-2 gap-4` — flat two columns
at every breakpoint, exactly what `rules/protocol.md:252` describes.
The line now matches the wording already in `rules/protocol.md:235-247` and
`guides/mobile.md:99`, and `AGENTS.md` §5 Commandment #5. One line replaces one
line: net-line delta zero, whole file 155 -> 155.
The prohibition itself stays where it lives (`rules/protocol.md`, linked from
SKILL.md:126) rather than being copied into the entry file, per that file's own
standing discipline at line 140.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EuPCi56cnGyykygi3z9w4m
…jectstack-ai#5912) `skills/objectui/SKILL.md` is the top-level file of the published skill — the one an agent loads before any guide — and line 81 prescribed `cols: { sm: 1, md: 2, lg: 4 }`. Nothing reads that key, and `sm` is not the base breakpoint. Measured against the schema and the renderer, not against the sibling docs: - `GridSchema` (`packages/types/src/layout.ts:202-209`) declares `columns`. - `grid.tsx:57-67` reads `columns.xs / .sm / .md / .lg / .xl` and treats `xs` as the base (`baseCols = schema.columns.xs ?? 1`). - Rendering the dead sample emits `grid grid-cols-2 gap-4` — flat two columns at every breakpoint, exactly what `rules/protocol.md:252` describes. The line now matches the wording already in `rules/protocol.md:235-247` and `guides/mobile.md:99`, and `AGENTS.md` §5 Commandment objectstack-ai#5. One line replaces one line: net-line delta zero, whole file 155 -> 155. The prohibition itself stays where it lives (`rules/protocol.md`, linked from SKILL.md:126) rather than being copied into the entry file, per that file's own standing discipline at line 140. Claude-Session: https://claude.ai/code/session_01EuPCi56cnGyykygi3z9w4m Co-authored-by: Claude <noreply@anthropic.com>
…chained-callback channel (objectstack-ai#5934) (objectstack-ai#7065) * feat(core,types,components)!: retire ActionRunner's legacy onSuccess chained-callback channel (objectui#5934) The maintainer ruled (2026-08-31, director batch objectstack-ai#5 item objectstack-ai#1) to retire the runner's pre-spec onSuccess meaning: ActionDef | ActionDef[], dispatched through executeChain. The spec strict-refuses a callback shape inside onSuccess at parse, so no validated metadata could ever reach the channel, and a producer census with a positive control found zero producers outside the channel's own pins. - core: ActionDef.onSuccess now derives the spec's { navigate, openIn } block (SpecActionInput['onSuccess']); handlePostExecution no longer falls through to executeChain; readOnSuccessNavigation is a shape guard on unparsed rows, not a discriminator. onFailure is untouched. - types: UIActionSchema declares onSuccess, derived from the spec, so the renderer view spells the key the four action surfaces forward. - components: the four renderers forward onSuccess uncast. - tests: the channel's pins now pin its absence; the spec-path pins are untouched and stay green. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_013hfmP9hoMd3dJwTh85J4yB * fix(changeset): core bump major -> minor per the repo's pinned-major release model objectui's major is pinned to the @objectstack family major; the repo's own breaking changes ship as minor with the break spelled out in the changeset body (scripts/check-changeset-no-major.mjs). The BREAKING declaration in the body is unchanged and now names the model explicitly. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_013hfmP9hoMd3dJwTh85J4yB --------- Co-authored-by: Claude <noreply@anthropic.com>
…osture (objectui#11705) (objectstack-ai#11711) Fixes objectstack-ai#11705 Clause-②: yes ## What `DefaultLoginPage` and `DefaultRegisterPage`, the auth pages `@object-ui/app-shell` exports (`examples/console-starter` mounts them at `/login` and `/register`), read `emailPassword.disableSignUp` alone. Under the default `invite_only` audience posture they offered "Sign up" and the full form to every visitor, and the server refused the finished form with `403 SELF_REGISTRATION_CLOSED`. They now call `decideSignUpOffer`, the decision the console's own pages have used since objectui#11691 (PR objectui#11703, landed as `daa7caf`). ## The move: one rule, no second copy - `apps/console/src/pages/auth/signUpOffer.ts` moves to `packages/app-shell/src/console/auth/signUpOffer.ts`. With comments and imports stripped, the code compares equal to BASE. - The bootstrap probe it reads moves too: `useBootstrapStatus` and `BootstrapStatus`, out of `apps/console/src/components/setupEntry.ts`, into `packages/app-shell/src/console/auth/bootstrapStatus.ts`. The function body also compares equal to BASE, and so does its base-URL expression. - Both are exported from the app-shell entry. The console's `LoginPage`, `RegisterPage` and `setupEntry.ts` (`decideSetupEntry` / `useSetupEntryMode` stay there) import them from `@object-ui/app-shell`. The console's copy is deleted, not left behind as a re-export shim. At BASE its only importers were those pages and the pin file, so no shim is needed. **Why `@object-ui/app-shell`, not `@object-ui/auth` (order, Zone 2 objectstack-ai#3).** The decision is not only about `AuthPublicConfig`. It also reads the invitation route `/accept-invitation/ID` that `DefaultAcceptInvitationPage` (app-shell) bounces from, and the `hasOwner` probe, a REST call. app-shell already owns all of the related pieces: the default pages, the refusal-copy maps the console already imports (`signInRefusalMessages`, `signUpRefusalMessages`), the accept-invitation page, and the `VITE_SERVER_URL` + `/api/v1/...` convention the probe uses. `@object-ui/auth` owns the client and the config type, and neither of those knows about routes or the probe. **The probe (Zone 2 objectstack-ai#2), measured before moving it.** app-shell had no equivalent. At BASE, `git grep` for `bootstrap-status` / `hasOwner` / `BootstrapStatus` over `packages/` returns zero hits. The same query over `apps/console/src` hits `setupEntry.ts`, `SetupPage.tsx` and their tests, which is the control. **Zone 2 objectstack-ai#1 confirmed.** At BASE the app-shell entry exports `LoginPage as DefaultLoginPage` and `RegisterPage as DefaultRegisterPage` from `console/auth/`, and `examples/console-starter/src/App.tsx` routes those two at `/login` and `/register`. Both pages read `cfg?.emailPassword?.disableSignUp === true` and nothing else. ## The default pages now - `DefaultLoginPage` reads `?redirect=`, the config, and the probe. The probe runs only when the posture is closed and the visitor is not an invitee. The page offers the link only for `form`, and the link carries `?redirect=`, which is how `/register` knows the visitor is an invitee. Before the config is read it behaves as before the posture existed, which is what the console login page does too. - `DefaultRegisterPage` renders nothing until the config is read, as before. `closed` bounces to `/login` and keeps `?redirect=`. `pending` renders nothing. `by-invitation` explains before any form, with the console's copy key `auth.register.errors.selfRegistrationClosed`. `form` renders the form, and its sign-in link carries `?redirect=`. - The README gains a "Default auth pages and the sign-up offer" section: a table of what each page offers per case, and a compiled example of the exported decision. ## Pins - **objectui#11691's 16 pins, none weakened.** The 5 decision cases moved with the decision into `packages/app-shell/src/console/auth/__tests__/signUpOffer-11691.test.ts`. Their `describe` block and the `configFor` helper are byte-identical to BASE. That includes the parity case against the spec's `AUDIENCE_POSTURES` / `audiencePermitsSelfRegistration`; app-shell already depends on `@objectstack/spec`. The 11 rendered console-page pins stay in `signUpFollowsPosture-11691.test.tsx`, and that block is byte-identical to BASE too. Only the header and the imports changed. - **11 new rendered pins** in `defaultPagesFollowPosture-11705.test.tsx`. They render the exported pages with real `@object-ui/auth` forms, a real `AuthProvider` over a real `createAuthClient`, and a real `I18nProvider`; only `fetch` is a stub. - Under `invite_only` with an owner, there is no "Sign up". - An invitation redirect still reaches registration: the link carries the redirect, the form submits, and the sign-up request is recorded. - With no owner yet, the link and the form stay. - `open`, `email_domain` and no posture are unchanged and make no probe. - `disableSignUp: true` hides everything, and `/register` bounces to `/login` keeping the redirect. ## Verification (head `54cb036`; the type-checks, the builds and the verbose 16-case run at `f133f3c`, which differs from `54cb036` only by `packages/app-shell/README.md`) - app-shell, auth directory: the two new files are 16 of 16 green (verbose run), and with the three existing default-page pins it is 5 files and 30 tests green. - app-shell, narrowed suite: the 17 test files that read the barrel or name a touched module (derived by `git grep`) are 17 files and 91 tests green. The full 1029-file app-shell suite is declared to CI: it was stopped locally after 30 minutes holding the shared lock with two agents queued, so it is NOT MEASURED here. - Console, `apps/console/src/pages/auth/` + `SetupRoute.test.tsx`: 12 files and 82 tests green. That covers the 11 kept posture pins and the 15 `SetupRoute` cases for `setupEntry.ts`. - `type-check`: app-shell exit 0 (its `tsconfig.test.json` lists both new test files, and the `registerRefusalCodes-11030` positive control is listed too), auth exit 0, and console exit 0. The console was checked after building its own closure: the first run's 23 errors were all five unbuilt plugins, so that run measured nothing. - `lint`: app-shell, console and auth exit 0 with 0 errors. None of their warnings is on a line this PR adds. - Builds: `@object-ui/auth` and `@object-ui/app-shell` built. The built `dist/index.d.ts` carries the new entry exports listed in the changeset's Clause-② paragraph. `audienceAdmitsUninvitedSignUp` is in `dist/` but not on the entry. - Root gates, all exit 0: `check:i18n-keys`, `check:phantom-deps`, `check:self-import`, `check:unreferenced-sources`, `check:side-effects-array`, `check:esm-specifiers`, `check:spec-symbols`, `check:published-tsconfig-exclude`, `check:readme-exports`, `check:new-line-citations`, `check:control-bytes`, `check:changeset-claims`, `check:pending-changeset-literals`, `check:test-path-roots`, `check:doc-snippets`, and `check-changeset-no-major` / `-presence` / `-fixed`, `check-type-check-coverage`, `check-vi-mock-override-shape`, `check-lint-coverage`. `check:readme-exports` judged 571 self-imports where it judged 566 before, so the README's five new names are read and real. `check:doc-snippets` covers the new README block: a deliberately broken copy of it on disk went red with TS2353 on that block, and was restored by hash. ## Reverse verification The fix was committed first (`f133f3c`). The mutation wrote BASE's two default pages into the tree, which the marker counts confirm: `decideSignUpOffer` 4/3 at HEAD, 0/0 when mutated. The new pin file then went **7 red, 4 green**, exactly the registered prediction. The red ones are: login under `invite_only` with an owner, with an invitation, and with no owner; register's notice, the invitation flow, the no-owner case, and the `disableSignUp` bounce that keeps the redirect. The 4 controls stayed green: login under `open`/`email_domain`, with no posture, and with `disableSignUp`, plus register under `open`. Restore: both files are back to their HEAD blob hashes, and `git diff HEAD` is 0 bytes. The tests import the pages by relative path, so the run read `src/` and no rebuild leg was needed. ## Eager closure (Zone 2 objectstack-ai#5) The console was built twice with `vite build` in this tree, at HEAD and with BASE's sources written back (trap-restored, verified by hash). Both builds have 331 eager chunks. Raw size grew by +4 bytes and gzip size by +77 bytes. `check:eager-closure` reads 3323.3 KB of 3330.4 KB at HEAD (headroom 7.0 KB), against headroom 7.1 KB at BASE. The move does not grow the closure. ## Acceptance notes - **File surface.** The claim's surface does not include `packages/auth/src/types.ts`. Its `audiencePosture` doc comment pointed at `pages/auth/signUpOffer.ts`, which this move deletes, so the comment, which ships in `dist/types.d.ts`, now names app-shell's `decideSignUpOffer` instead. The change is comment-only. Auth was built, type-checked and linted after it. - **Observation, not filed.** The default pages navigate to `/` after a successful sign-in or sign-up and never honour `?redirect=` afterwards. An invitee bounced by `DefaultAcceptInvitationPage` therefore registers but lands on `/`, not back on the invitation. Nothing in this repo mounts both (`examples/console-starter` has no `/accept-invitation` route), and nothing here changes it. Carrier: none. - **Observation, not filed.** The console's `SetupPage.tsx` still runs its own `bootstrap-status` fetch beside `useBootstrapStatus`. That is pre-existing and untouched. Carrier: none. --- _Generated by [Claude Code](https://claude.ai/code/session_01FngvPpdrnhHMdHHq6vwwju)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
Implements drag-and-drop for reordering components within the designer canvas. Previously, components could only be added from the palette; they could not be rearranged.
Changes
DesignerContext (
packages/designer/src/context/DesignerContext.tsx)draggingNodeIdstate to track component being draggedmoveNode()to relocate nodes in schema tree with circular reference validationfindNodeById(),moveNodeInTree()Canvas (
packages/designer/src/components/Canvas.tsx)useEffectthat attaches drag handlers to elements withdata-obj-idTests (
packages/designer/src/__tests__/drag-and-drop.test.tsx)draggingNodeIdstate management andmoveNode()operationsUsage
Components in the canvas now show a grab cursor and can be dragged into other containers. Drop position is currently fixed at index 0 (beginning of container).
Screenshots
Initial state with two cards:

Selected card with visual feedback:

Limitations
Original prompt
💡 You can make Copilot smarter by setting up custom instructions, customizing its development environment and configuring Model Context Protocol (MCP) servers. Learn more Copilot coding agent tips in the docs.