Skip to content

Implement component reordering via drag-and-drop in designer canvas - #5

Merged
huangyiirene merged 6 commits into
mainfrom
copilot/add-draggable-components-in-designer
Jan 13, 2026
Merged

huangyiirene merged 6 commits into
mainfrom
copilot/add-draggable-components-in-designer

Conversation

Copilot AI commented Jan 13, 2026 •

Copy link
Copy Markdown
Contributor

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)

  • Added draggingNodeId state to track component being dragged
  • Implemented moveNode() to relocate nodes in schema tree with circular reference validation
  • Added helper functions: findNodeById(), moveNodeInTree()

Canvas (packages/designer/src/components/Canvas.tsx)

  • Made rendered components draggable via useEffect that attaches drag handlers to elements with data-obj-id
  • Enhanced drop handler to differentiate between palette drops (add) and canvas drops (move)
  • Added visual feedback: grab cursor, 50% opacity on drag, dashed outline on drop target
  • Root node remains non-draggable to prevent schema corruption

Tests (packages/designer/src/__tests__/drag-and-drop.test.tsx)

  • Added unit tests for draggingNodeId state management and moveNode() operations
  • All 6 tests passing

Usage

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:
Designer Initial

Selected card with visual feedback:
Card Selected

Limitations

  • Dropped components always insert at container start. Future enhancement: calculate insertion index from mouse position.
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.

Copilot AI and others added 4 commits January 13, 2026 14:03
…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>
Copilot AI changed the title [WIP] Add draggable components in the designer Implement component reordering via drag-and-drop in designer canvas Jan 13, 2026
Copilot AI requested a review from huangyiirene January 13, 2026 14:14
@huangyiirene
huangyiirene requested review from Copilot and removed request for huangyiirene January 13, 2026 14:22

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Comment on lines +100 to +142
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]);

Copilot AI Jan 13, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copilot uses AI. Check for mistakes.
e.stopPropagation();
if (e.dataTransfer) {
e.dataTransfer.effectAllowed = 'move';
e.dataTransfer.setData('text/plain', nodeId || '');

Copilot AI Jan 13, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Suggested change
e.dataTransfer.setData('text/plain', nodeId || '');
e.dataTransfer.setData('text/plain', nodeId);

Copilot uses AI. Check for mistakes.
@github-actions

Copy link
Copy Markdown
Contributor

✅ All checks passed!

  • ✅ Type check passed
  • ✅ Tests passed
  • ✅ Lint check completed

@huangyiirene
huangyiirene marked this pull request as ready for review January 13, 2026 14:30
@huangyiirene
huangyiirene merged commit adc057c into main Jan 13, 2026
5 checks passed
Copilot AI added a commit that referenced this pull request Jan 24, 2026
- 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>
Copilot AI added a commit that referenced this pull request Feb 20, 2026
…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>
os-zhuang pushed a commit that referenced this pull request Aug 23, 2026
`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
akarma-synetal pushed a commit to akarma-synetal/objectui that referenced this pull request Sep 1, 2026
…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>
akarma-synetal pushed a commit to akarma-synetal/objectui that referenced this pull request Sep 1, 2026
…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>
akarma-synetal pushed a commit to akarma-synetal/objectui that referenced this pull request Oct 7, 2026
…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>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants