@protobase/ui 0.24.0 → 0.24.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,13 @@
1
+ //#region src/app/hand-order/drop-rule.d.ts
2
+ /** A record dropped while re-ranking: the records as the API gives them, in the order the drop leaves them, and its place among them, 0 the first. */
3
+ export type Drop = {
4
+ record: Record<string, unknown>;
5
+ order: Record<string, unknown>[];
6
+ index: number;
7
+ };
8
+ /**
9
+ * Where a re-ranked record may land, a view's `drag.rule`: why `drop.record` may not be at `drop.index`, in the app's own
10
+ * words, or nothing when it may.
11
+ */
12
+ export type DropRule = (drop: Drop) => string | undefined;
13
+ //#endregion
@@ -0,0 +1,28 @@
1
+ import { arrayMove } from "@dnd-kit/sortable";
2
+ //#region src/app/hand-order/drop-rule.ts
3
+ /**
4
+ * Where the record at `from` of `records` lands when dropped at `to`: there when `rule` allows it, else at the first
5
+ * place below that it allows, with the reason it refused `to`. With none below, it stays at `from`, with that reason.
6
+ */
7
+ const allowedDrop = (records, from, to, rule) => {
8
+ const record = records[from];
9
+ let reason;
10
+ for (let index = to; index < records.length; index++) {
11
+ const refused = rule({
12
+ record,
13
+ order: arrayMove(records, from, index),
14
+ index
15
+ });
16
+ if (refused === void 0) return {
17
+ to: index,
18
+ ...reason !== void 0 && { reason }
19
+ };
20
+ reason ??= refused;
21
+ }
22
+ return {
23
+ to: from,
24
+ reason
25
+ };
26
+ };
27
+ //#endregion
28
+ export { allowedDrop };
@@ -1,4 +1,5 @@
1
1
  import { Skeleton } from "../../primitives/skeleton.js";
2
+ import { Problem } from "../pages/problem.js";
2
3
  import { recordKey, recordTitle } from "../../live/model-helpers.js";
3
4
  import { loadedFields } from "../displays/list-displays.js";
4
5
  import { useAdminMeta } from "../meta-gate.js";
@@ -10,16 +11,16 @@ import { embeddedColumns } from "../embedded-list/embedded-columns.js";
10
11
  import { text } from "./messages.js";
11
12
  import { useHandOrder } from "./use-hand-order.js";
12
13
  import { useMemo } from "react";
13
- import { jsx } from "react/jsx-runtime";
14
+ import { jsx, jsxs } from "react/jsx-runtime";
14
15
  //#region src/app/hand-order/hand-sorted-list.tsx
15
16
  /**
16
17
  * The records in their order by hand, in the list's table with a drag handle in each row's checkbox place: dragging a
17
- * row, or moving it with the keyboard from its handle, renumbers them all. The page scrolls them, and a row still opens
18
- * its record.
18
+ * row, or moving it with the keyboard from its handle, renumbers them all, landing where the list's `drag.rule` allows.
19
+ * The page scrolls them, and a row still opens its record.
19
20
  */
20
21
  const HandSortedList = ({ model, view, field, filter, columns, sort }) => {
21
22
  const { resources, views } = useAdminMeta();
22
- const { fields: fieldTypes } = useProjectUi();
23
+ const { fields: fieldTypes, dropRules } = useProjectUi();
23
24
  const { go, href } = useAppAddress();
24
25
  const sorts = sort !== void 0;
25
26
  const specs = useMemo(() => embeddedColumns(model, view, columns, {
@@ -43,17 +44,25 @@ const HandSortedList = ({ model, view, field, filter, columns, sort }) => {
43
44
  view,
44
45
  specs
45
46
  ]);
47
+ const ruleName = view?.list?.drag?.rule;
48
+ const rule = ruleName === void 0 ? void 0 : dropRules?.[ruleName];
46
49
  const order = useHandOrder({
47
50
  model,
48
51
  field,
49
52
  filter,
50
- fields
53
+ fields,
54
+ ...rule && { rule }
51
55
  });
52
56
  const rows = order.rows;
53
57
  const source = useMemo(() => rows && {
54
58
  total: rows.length,
55
59
  rowAt: (index) => rows[index]
56
60
  }, [rows]);
61
+ if (ruleName !== void 0 && !rule) return /* @__PURE__ */ jsxs(Problem, { children: [
62
+ "No drop rule is registered as ",
63
+ ruleName,
64
+ ". Give it in dropRules in protobase.ui.tsx, or load the plugin that provides it."
65
+ ] });
57
66
  if (order.loading) return /* @__PURE__ */ jsx("div", {
58
67
  className: "flex flex-col gap-2",
59
68
  children: Array.from({ length: 3 }, (_, index) => /* @__PURE__ */ jsx(Skeleton, { className: "h-4 w-full" }, index))
@@ -9,7 +9,9 @@ const text = defineMessages({
9
9
  couldNotChange: "Could not change the order",
10
10
  orderChanged: "Order changed",
11
11
  undo: "Undo",
12
- couldNotUndo: "Could not undo"
12
+ couldNotUndo: "Could not undo",
13
+ movedToPlace: (place) => `Moved to place ${place}`,
14
+ notMoved: "Left in its place"
13
15
  },
14
16
  nl: {
15
17
  done: "Klaar",
@@ -19,7 +21,9 @@ const text = defineMessages({
19
21
  couldNotChange: "Kon de volgorde niet wijzigen",
20
22
  orderChanged: "Volgorde gewijzigd",
21
23
  undo: "Ongedaan maken",
22
- couldNotUndo: "Kon niet ongedaan maken"
24
+ couldNotUndo: "Kon niet ongedaan maken",
25
+ movedToPlace: (place) => `Verplaatst naar plaats ${place}`,
26
+ notMoved: "Op zijn plaats gelaten"
23
27
  }
24
28
  });
25
29
  //#endregion
@@ -3,11 +3,13 @@ import { useClient } from "../../data/api-provider.js";
3
3
  import { useToast } from "../../toasts/toast-provider.js";
4
4
  import { recordId, recordKey } from "../../live/model-helpers.js";
5
5
  import { refusalReason } from "../displays/board-columns.js";
6
+ import { displayRow } from "../displays/list-displays.js";
6
7
  import { useAdminMeta } from "../meta-gate.js";
7
8
  import { useRefreshResource } from "../pages/use-layout-data.js";
8
9
  import { listLabels, withLabels } from "../../live/relation-labels.js";
9
10
  import { reorderSpan } from "./reorder-span.js";
10
11
  import { undoOps } from "./undo-ops.js";
12
+ import { allowedDrop } from "./drop-rule.js";
11
13
  import { text } from "./messages.js";
12
14
  import { useRef, useState } from "react";
13
15
  import { encodeKey } from "@protobase/schema/internal";
@@ -19,9 +21,10 @@ import { arrayMove } from "@dnd-kit/sortable";
19
21
  * one elsewhere. A move shows at once and saves only the records whose place changed (see `reorderSpan`) in one batch
20
22
  * `reorder` with their ETags; moves made while one saves are saved after it, with the values and ETags it gave back.
21
23
  * Each save says so with **Undo**, which puts back the values it changed. A refusal puts them back as the server has
22
- * them and says why; either way every list of the resource reloads.
24
+ * them and says why; either way every list of the resource reloads. A drop `rule` refuses moves to the first place below
25
+ * that it allows, with a warning giving its reason.
23
26
  */
24
- const useHandOrder = ({ model, field, filter, fields }) => {
27
+ const useHandOrder = ({ model, field, filter, fields, rule }) => {
25
28
  const client = useClient();
26
29
  const toast = useToast();
27
30
  const refresh = useRefreshResource();
@@ -116,9 +119,11 @@ const useHandOrder = ({ model, field, filter, fields }) => {
116
119
  latest.current = void 0;
117
120
  setMoved(void 0);
118
121
  };
119
- const move = (from, to) => {
122
+ const move = (from, dropped) => {
120
123
  const rows = latest.current ?? loaded.data;
121
- if (!rows || from === to) return;
124
+ if (!rows || from === dropped) return;
125
+ const to = rule ? placeAllowed(rows, from, dropped, rule) : dropped;
126
+ if (from === to) return;
122
127
  latest.current = arrayMove(rows, from, to);
123
128
  const first = Math.min(from, to);
124
129
  const last = Math.max(from, to);
@@ -133,6 +138,15 @@ const useHandOrder = ({ model, field, filter, fields }) => {
133
138
  waiting.current += 1;
134
139
  saving.current = saving.current.then(save).catch(refused(text("couldNotChange"))).then(settle);
135
140
  };
141
+ const placeAllowed = (rows, from, dropped, rule) => {
142
+ const allowed = allowedDrop(rows.map((row) => displayRow(model, row).record), from, dropped, rule);
143
+ if (allowed.reason !== void 0) toast.show({
144
+ state: "warning",
145
+ title: allowed.to === from ? text("notMoved") : text("movedToPlace", allowed.to + 1),
146
+ description: allowed.reason
147
+ });
148
+ return allowed.to;
149
+ };
136
150
  const undoSave = (ops) => {
137
151
  if (undoToast.current !== void 0) toast.dismiss(undoToast.current);
138
152
  undoToast.current = void 0;
@@ -2,7 +2,7 @@ import { ProjectUi } from "./project-ui.js";
2
2
  //#region src/app/pages/merge-ui.d.ts
3
3
  /**
4
4
  * One UI config from several, each after the ones it `extends`, the last being the app's own: components, action
5
- * handlers and field types are all kept, and one name defined by two configs is an error naming both; every shell slot
5
+ * handlers, field types and drop rules are all kept, and one name defined by two configs is an error naming both; every shell slot
6
6
  * and `beforeSignIn` component is drawn, the extended configs' first, except the logo, the last config's to set one.
7
7
  */
8
8
  export declare const mergeUi: (...uis: ProjectUi[]) => ProjectUi;
@@ -35,7 +35,7 @@ const around = (wrappers) => {
35
35
  };
36
36
  /**
37
37
  * One UI config from several, each after the ones it `extends`, the last being the app's own: components, action
38
- * handlers and field types are all kept, and one name defined by two configs is an error naming both; every shell slot
38
+ * handlers, field types and drop rules are all kept, and one name defined by two configs is an error naming both; every shell slot
39
39
  * and `beforeSignIn` component is drawn, the extended configs' first, except the logo, the last config's to set one.
40
40
  */
41
41
  const mergeUi = (...uis) => {
@@ -43,6 +43,7 @@ const mergeUi = (...uis) => {
43
43
  const components = union("The component", named, (ui) => ui.components);
44
44
  const actions = union("The action handler", named, (ui) => ui.actions);
45
45
  const fields = union("The field type", named, (ui) => ui.fields);
46
+ const dropRules = union("The drop rule", named, (ui) => ui.dropRules);
46
47
  const actionSlot = slot(named.flatMap(({ ui }) => ui.shell?.actions ? [ui.shell.actions] : []));
47
48
  const rightPanel = slot(named.flatMap(({ ui }) => ui.shell?.rightPanel ? [ui.shell.rightPanel] : []));
48
49
  const logo = named.findLast(({ ui }) => ui.shell?.logo)?.ui.shell?.logo;
@@ -53,6 +54,7 @@ const mergeUi = (...uis) => {
53
54
  components,
54
55
  actions,
55
56
  ...Object.keys(fields).length > 0 && { fields },
57
+ ...Object.keys(dropRules).length > 0 && { dropRules },
56
58
  ...(actionSlot || rightPanel || logo) && { shell: {
57
59
  ...actionSlot && { actions: actionSlot },
58
60
  ...rightPanel && { rightPanel },
@@ -1,5 +1,6 @@
1
1
  import { ShellLogoProps } from "../../app-shell/logo.js";
2
2
  import { FieldWidget } from "../field-types/field-widget.js";
3
+ import { DropRule } from "../hand-order/drop-rule.js";
3
4
  import { ComponentType, ReactNode } from "react";
4
5
  import { Client } from "@protobase/client";
5
6
  import { AppAddress } from "@protobase/schema";
@@ -43,9 +44,9 @@ export type BeforeSignInProps = {
43
44
  /**
44
45
  * The project's own React code for composed pages: custom components by the name their layout declares
45
46
  * (`component('UsageChart')`) or a widget part names, handlers for named actions that have no built-in behaviour,
46
- * field types by the name a view's `.widget(name)` chooses, and the shell's slots. `extends` lists UI configs merged in
47
- * before it (see `mergeUi`), or whole `protobase.ui.tsx` modules (`import * as tasksUi from '...'`), their exported
48
- * components included; `name` names it in their errors.
47
+ * field types by the name a view's `.widget(name)` chooses, drop rules by the name a list's `drag.rule` gives, and the
48
+ * shell's slots. `extends` lists UI configs merged in before it (see `mergeUi`), or whole `protobase.ui.tsx` modules
49
+ * (`import * as tasksUi from '...'`), their exported components included; `name` names it in their errors.
49
50
  */
50
51
  export type ProjectUi = {
51
52
  name?: string;
@@ -54,6 +55,8 @@ export type ProjectUi = {
54
55
  actions?: Record<string, ActionHandler>;
55
56
  /** Field types by name, each with the storage types it shows and its components (see `FieldWidget`). */
56
57
  fields?: Record<string, FieldWidget>;
58
+ /** Where re-ranked records may land, by the name a view's `drag.rule` gives (see `DropRule`). */
59
+ dropRules?: Record<string, DropRule>;
57
60
  /**
58
61
  * Rendered in the shell on every page: `actions` at the end of the top bar, `rightPanel` beside the page, and `logo`
59
62
  * at the top of the sidebar in place of Protobase's (see `ShellLogoProps`).
package/dist/index.d.ts CHANGED
@@ -1,5 +1,6 @@
1
1
  import { LogoMark, ShellLogoProps } from "./app-shell/logo.js";
2
2
  import { FieldWidget, FieldWidgetEditorProps, FieldWidgetProps } from "./app/field-types/field-widget.js";
3
+ import { Drop, DropRule } from "./app/hand-order/drop-rule.js";
3
4
  import { ActionContext, ActionHandler, BeforeSignInProps, ProjectUi, definePlugin, defineUi } from "./app/pages/project-ui.js";
4
5
  import { PluginInfo, usePlugin } from "./plugins/plugin-context.js";
5
6
  import { App, AppProps } from "./app/app.js";
@@ -61,4 +62,4 @@ import { Section } from "./record-view/section.js";
61
62
  import { Form, FormProps } from "./app/create/form.js";
62
63
  import { RecordPicker, RecordPickerProps } from "./app/record-picker.js";
63
64
  import { Spinner } from "./primitives/spinner.js";
64
- export { ActionCard, type ActionCardProps, type ActionContext, type ActionHandler, ActionInputError, type ActionInputProps, ApiProvider, type ApiProviderProps, App, AppLink, type AppProps, AssistantDock, type AssistantDockProps, AuthLayout, type AuthLayoutProps, type BackendData, type BackendRefresh, type BeforeSignInProps, Button, type ButtonProps, type CardAction, type CardTone, ChatMessage, ChatThread, type ChatThreadProps, type ChoiceOption, Choices, type ChoicesProps, CodeForm, type CodeFormProps, CompactTable, type CompactTableProps, Composer, type ComposerProps, type CurrentAddress, Dialog, type DialogProps, type DiffLine, DiffView, type DiffViewProps, Field, FieldList, type FieldProps, type FieldWidget, type FieldWidgetEditorProps, type FieldWidgetProps, Form, type FormProps, Input, type InputProps, type LayoutRecord, type ListAddress, type ListChange, type ListDisplay, type ListDisplayRow, LogoMark, MenuSelect, type MenuSelectProps, type MetaData, OtpInput, type OtpInputProps, PartView, type PluginInfo, type ProjectUi, type RecordCardProps, RecordLink, RecordPicker, type RecordPickerProps, ResendCodeButton, type ResendCodeButtonProps, ResponsiveDialog, type ResponsiveDialogProps, Section, Segmented, type SegmentedOption, type SegmentedProps, type ShellLogoProps, SidePanel, type SidePanelProps, SignInPrompt, type SignInPromptProps, Spinner, type Step, StepList, type StepState, Textarea, type TextareaProps, buttonClass, definePlugin, defineUi, diffStats, isConflict, mergeUi, parseDiff, pluginApi, protobaseVersion, uiFromModule, useApiToken, useAppAddress, useAssistant, useAssistantClient, useBackendData, useClient, useFacets, useFieldControl, useHistogram, useLayoutRecord, useList, useListDisplay, useMeta, usePlugin, useRecord, useResourceClient, useSeries, useUpdateRecord };
65
+ export { ActionCard, type ActionCardProps, type ActionContext, type ActionHandler, ActionInputError, type ActionInputProps, ApiProvider, type ApiProviderProps, App, AppLink, type AppProps, AssistantDock, type AssistantDockProps, AuthLayout, type AuthLayoutProps, type BackendData, type BackendRefresh, type BeforeSignInProps, Button, type ButtonProps, type CardAction, type CardTone, ChatMessage, ChatThread, type ChatThreadProps, type ChoiceOption, Choices, type ChoicesProps, CodeForm, type CodeFormProps, CompactTable, type CompactTableProps, Composer, type ComposerProps, type CurrentAddress, Dialog, type DialogProps, type DiffLine, DiffView, type DiffViewProps, type Drop, type DropRule, Field, FieldList, type FieldProps, type FieldWidget, type FieldWidgetEditorProps, type FieldWidgetProps, Form, type FormProps, Input, type InputProps, type LayoutRecord, type ListAddress, type ListChange, type ListDisplay, type ListDisplayRow, LogoMark, MenuSelect, type MenuSelectProps, type MetaData, OtpInput, type OtpInputProps, PartView, type PluginInfo, type ProjectUi, type RecordCardProps, RecordLink, RecordPicker, type RecordPickerProps, ResendCodeButton, type ResendCodeButtonProps, ResponsiveDialog, type ResponsiveDialogProps, Section, Segmented, type SegmentedOption, type SegmentedProps, type ShellLogoProps, SidePanel, type SidePanelProps, SignInPrompt, type SignInPromptProps, Spinner, type Step, StepList, type StepState, Textarea, type TextareaProps, buttonClass, definePlugin, defineUi, diffStats, isConflict, mergeUi, parseDiff, pluginApi, protobaseVersion, uiFromModule, useApiToken, useAppAddress, useAssistant, useAssistantClient, useBackendData, useClient, useFacets, useFieldControl, useHistogram, useLayoutRecord, useList, useListDisplay, useMeta, usePlugin, useRecord, useResourceClient, useSeries, useUpdateRecord };
package/dist/package.js CHANGED
@@ -1,5 +1,5 @@
1
1
  //#region package.json
2
- var version = "0.24.0";
2
+ var version = "0.24.1";
3
3
  var protobase = { "pluginApi": 1 };
4
4
  //#endregion
5
5
  export { protobase, version };