@lobb-js/studio 0.37.1 → 0.39.0

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.
Files changed (29) hide show
  1. package/dist/actions.d.ts +1 -0
  2. package/dist/components/dataTable/dataTable.svelte +3 -0
  3. package/dist/components/dataTable/dataTable.svelte.d.ts +1 -0
  4. package/dist/components/dataTable/fieldPicker.svelte +61 -0
  5. package/dist/components/dataTable/fieldPicker.svelte.d.ts +9 -0
  6. package/dist/components/dataTable/filter.svelte +469 -238
  7. package/dist/components/dataTable/filter.svelte.d.ts +1 -4
  8. package/dist/components/dataTable/filterButton.svelte +24 -6
  9. package/dist/components/dataTable/header.svelte +9 -31
  10. package/dist/components/dataTable/header.svelte.d.ts +1 -0
  11. package/dist/components/dataTable/sort.svelte +169 -104
  12. package/dist/components/dataTable/sortButton.svelte +33 -7
  13. package/dist/components/dataTable/table.svelte +2 -1
  14. package/dist/components/dataTable/table.svelte.d.ts +1 -0
  15. package/dist/components/dataTablePopup/dataTablePopup.svelte +7 -0
  16. package/dist/components/dataTablePopup/dataTablePopup.svelte.d.ts +1 -0
  17. package/dist/components/importButton.svelte +154 -31
  18. package/package.json +4 -3
  19. package/src/lib/actions.ts +1 -0
  20. package/src/lib/components/dataTable/dataTable.svelte +3 -0
  21. package/src/lib/components/dataTable/fieldPicker.svelte +61 -0
  22. package/src/lib/components/dataTable/filter.svelte +469 -238
  23. package/src/lib/components/dataTable/filterButton.svelte +24 -6
  24. package/src/lib/components/dataTable/header.svelte +9 -31
  25. package/src/lib/components/dataTable/sort.svelte +169 -104
  26. package/src/lib/components/dataTable/sortButton.svelte +33 -7
  27. package/src/lib/components/dataTable/table.svelte +2 -1
  28. package/src/lib/components/dataTablePopup/dataTablePopup.svelte +7 -0
  29. package/src/lib/components/importButton.svelte +154 -31
@@ -1,6 +1,6 @@
1
1
  <script lang="ts">
2
2
  import * as Popover from "../ui/popover/index.js";
3
- import { ListFilter } from "lucide-svelte";
3
+ import { Settings2 } from "lucide-svelte";
4
4
  import { buttonVariants } from "../ui/button";
5
5
  import Filter from "./filter.svelte";
6
6
 
@@ -15,6 +15,22 @@
15
15
  showText = true,
16
16
  collectionName,
17
17
  }: Props = $props();
18
+
19
+ // Count rules, not top-level fields: a single field with two
20
+ // operators ({age: {$gte, $lte}}) is two rules, not one. Unmanaged
21
+ // $and / $or carriers count as one rule each (legacy filter shape).
22
+ function countRules(f: Record<string, any>): number {
23
+ let n = 0;
24
+ for (const [key, value] of Object.entries(f ?? {})) {
25
+ if (key.startsWith("$")) { n += 1; continue; }
26
+ if (value && typeof value === "object" && !Array.isArray(value)) {
27
+ n += Object.keys(value).length;
28
+ } else {
29
+ n += 1;
30
+ }
31
+ }
32
+ return n;
33
+ }
18
34
  </script>
19
35
 
20
36
  <Popover.Root>
@@ -22,18 +38,20 @@
22
38
  class={buttonVariants({
23
39
  variant: "ghost",
24
40
  size: "sm",
41
+ class: "text-muted-foreground",
25
42
  })}
26
43
  >
27
- <ListFilter />
44
+ <Settings2 />
28
45
  {#if showText}
29
- {#if Object.keys(filter).length}
30
- Filtered by {Object.keys(filter).length} rules
46
+ {@const n = countRules(filter)}
47
+ {#if n}
48
+ Filtered by {n} {n === 1 ? "rule" : "rules"}
31
49
  {:else}
32
50
  Filter
33
51
  {/if}
34
52
  {/if}
35
53
  </Popover.Trigger>
36
- <Popover.Content class="w-screen max-w-100 p-0">
37
- <Filter bind:filter {collectionName} isFirst={true} />
54
+ <Popover.Content class="w-screen max-w-[36rem] p-0">
55
+ <Filter bind:filter {collectionName} />
38
56
  </Popover.Content>
39
57
  </Popover.Root>
@@ -4,7 +4,6 @@
4
4
  import type { ParentContext } from "./dataTable.svelte";
5
5
  import CanAccess from "../canAccess.svelte";
6
6
  import { Download, ListRestart, LoaderCircle, Plus, Trash, Link } from "lucide-svelte";
7
- import LlmButton from "../LlmButton.svelte";
8
7
  import FilterButton from "./filterButton.svelte";
9
8
  import SortButton from "./sortButton.svelte";
10
9
  import Button from "../ui/button/button.svelte";
@@ -26,6 +25,7 @@
26
25
  onLink?: (record: any) => void;
27
26
  onCreate?: (changes: Changes) => void;
28
27
  showImport?: boolean;
28
+ showFilter?: boolean;
29
29
  loading?: boolean;
30
30
  left?: Snippet<[]>;
31
31
  }
@@ -38,6 +38,7 @@
38
38
  onLink,
39
39
  onCreate,
40
40
  showImport = true,
41
+ showFilter = true,
41
42
  loading = false,
42
43
  left
43
44
  }: Props = $props();
@@ -122,41 +123,18 @@
122
123
  {selectedRecords.length > 1 ? "records" : "record"}
123
124
  </Button>
124
125
  {:else}
125
- <FilterButton
126
- bind:filter={params.filter}
127
- showText={!headerIsSmall}
128
- {collectionName}
129
- />
126
+ {#if showFilter}
127
+ <FilterButton
128
+ bind:filter={params.filter}
129
+ showText={!headerIsSmall}
130
+ {collectionName}
131
+ />
132
+ {/if}
130
133
  <SortButton
131
134
  {collectionName}
132
135
  bind:sort={params.sort}
133
136
  showText={!headerIsSmall}
134
137
  />
135
- <LlmButton
136
- variant="outline"
137
- title="Filter table with AI"
138
- description="Tell the AI how do you want to filter the table"
139
- size="sm"
140
- format={{
141
- type: "json_object",
142
- }}
143
- messages={[
144
- {
145
- role: "system",
146
- content: [
147
- "You will receive natural language queries from a user describing how they want to filter a table list view.",
148
- "Your task is to generate a filter object based on the user's prompt.",
149
- `This is the schema of the filter json: ${JSON.stringify(ctx.meta.filter.filter_schema)}`,
150
- `This is the schema of the current collection: ${JSON.stringify(ctx.meta.collections[collectionName])}`,
151
- ].join(" "),
152
- },
153
- ]}
154
- onApiResponseComplete={async (res) => {
155
- params.filter = JSON.parse(res);
156
- }}
157
- >
158
- {headerIsSmall ? "" : "Filter with AI"}
159
- </LlmButton>
160
138
  {/if}
161
139
  </div>
162
140
  <div>
@@ -2,12 +2,14 @@
2
2
  import * as _ from "lodash-es";
3
3
 
4
4
  import * as Popover from "../ui/popover/index.js";
5
- import { ArrowDown, ArrowUp, GripVertical, Plus, X } from "lucide-svelte";
5
+ import * as Select from "../ui/select/index.js";
6
+ import { GripVertical, Plus, X } from "lucide-svelte";
6
7
  import Button, { buttonVariants } from "../ui/button/button.svelte";
7
- import Label from "../ui/label/label.svelte";
8
- import Switch from "../ui/switch/switch.svelte";
9
8
  import { getStudioContext } from "../../context";
10
9
  import { getFieldIcon } from "./utils";
10
+ import { dndzone } from "svelte-dnd-action";
11
+ import { flip } from "svelte/animate";
12
+ import FieldPicker from "./fieldPicker.svelte";
11
13
 
12
14
  const { ctx } = getStudioContext();
13
15
 
@@ -21,108 +23,174 @@
21
23
 
22
24
  let popoverOpen = $state(false);
23
25
 
26
+ // Fields not currently used by any rule. Used to decide whether the
27
+ // "Add a sort rule" trigger is shown at all.
24
28
  function getFieldNames() {
25
29
  const options = Object.keys(
26
30
  ctx.meta.collections[collectionName].fields,
27
31
  );
28
- const existingPropertiesNames = Object.keys(sort);
29
- const filteredOptions = _.difference(options, existingPropertiesNames);
30
- return filteredOptions;
32
+ const used = items.map((r) => r.id);
33
+ return _.difference(options, used);
31
34
  }
32
35
 
33
- function moveProperty<T extends Record<string, any>>(
34
- obj: T,
35
- propertyToMove: keyof T,
36
- direction: "up" | "down",
37
- steps: number = 1,
38
- ): T {
39
- const keys = Object.keys(obj) as Array<keyof T>;
40
- const currentIndex = keys.indexOf(propertyToMove);
41
-
42
- if (currentIndex === -1) {
43
- console.warn(
44
- `Property '${String(propertyToMove)}' not found in the object.`,
45
- );
46
- return { ...obj };
47
- }
36
+ // The field-name <Select> for an existing rule needs to list every
37
+ // field that isn't already used by *another* rule (plus its own
38
+ // current field). That way the user can swap a rule's field to any
39
+ // unused one without colliding with the others.
40
+ function getFieldOptionsForRule(currentField: string): string[] {
41
+ const all = Object.keys(ctx.meta.collections[collectionName].fields);
42
+ const usedByOthers = new Set(
43
+ items.map((r) => r.id).filter((id) => id !== currentField),
44
+ );
45
+ return all.filter((f) => !usedByOthers.has(f));
46
+ }
48
47
 
49
- const [removedKey] = keys.splice(currentIndex, 1) as [keyof T];
50
-
51
- let newIndex: number;
52
- if (direction === "up") {
53
- newIndex = Math.max(0, currentIndex - steps);
54
- } else if (direction === "down") {
55
- newIndex = Math.min(keys.length, currentIndex + steps);
56
- } else {
57
- console.warn(
58
- `Invalid direction specified: '${direction}'. Use 'up' or 'down'.`,
59
- );
60
- return { ...obj };
48
+ // Push items → sort. Strips any in-flight shadow rows whose ids
49
+ // don't correspond to a real schema field. Called explicitly from
50
+ // each mutator (and from finalize) instead of via $effect — that
51
+ // way a drag's `consider` events don't spam the parent's sort prop
52
+ // and trigger a refetch on every frame. We reassign the whole
53
+ // binding (instead of mutating in place) because the parent's
54
+ // refetch watcher only fires on identity changes.
55
+ function commit() {
56
+ const validIds = ctx.meta.collections[collectionName].fields;
57
+ const next: Record<string, "asc" | "desc"> = {};
58
+ for (const r of items) {
59
+ if (r.id in validIds) next[r.id] = r.direction;
61
60
  }
61
+ sort = next;
62
+ }
63
+
64
+ function renameField(oldName: string, newName: string) {
65
+ if (oldName === newName) return;
66
+ items = items.map((r) => (r.id === oldName ? { ...r, id: newName } : r));
67
+ commit();
68
+ }
62
69
 
63
- keys.splice(newIndex, 0, removedKey);
70
+ function setDirection(fieldId: string, dir: "asc" | "desc") {
71
+ items = items.map((r) => (r.id === fieldId ? { ...r, direction: dir } : r));
72
+ commit();
73
+ }
74
+
75
+ function removeRule(fieldId: string) {
76
+ items = items.filter((r) => r.id !== fieldId);
77
+ commit();
78
+ }
79
+
80
+ function addRule(fieldId: string) {
81
+ items = [...items, { id: fieldId, direction: "asc" }];
82
+ commit();
83
+ }
64
84
 
65
- const newObject: T = {} as T;
66
- keys.forEach((key) => {
67
- newObject[key] = obj[key];
68
- });
85
+ // svelte-dnd-action needs to own an array of {id} objects so it can
86
+ // stamp placeholder shadows during drag. We keep that array as local
87
+ // state and push into `sort` on every change — never the other way
88
+ // around, so the shadow item never leaks into the parent's filter.
89
+ type Rule = { id: string; direction: "asc" | "desc" };
90
+ let items = $state<Rule[]>(
91
+ Object.entries(sort).map(([id, direction]) => ({
92
+ id,
93
+ direction: direction as "asc" | "desc",
94
+ })),
95
+ );
69
96
 
70
- return newObject;
97
+ function handleDndConsider(e: CustomEvent<{ items: Rule[] }>) {
98
+ // Mid-drag: update local items so the row tracks the cursor, but
99
+ // do NOT touch `sort` — that would refetch the table every frame.
100
+ items = e.detail.items;
71
101
  }
102
+ function handleDndFinalize(e: CustomEvent<{ items: Rule[] }>) {
103
+ items = e.detail.items;
104
+ commit();
105
+ }
106
+
72
107
  </script>
73
108
 
74
109
  <div class="flex flex-col gap-2 p-2 text-muted-foreground">
75
- {#if Object.keys(sort).length}
76
- <div class="flex flex-col gap-2 text-muted-foreground">
77
- {#each Object.entries(sort) as [fieldName, order]}
78
- <div class="flex items-center justify-between text-xs">
79
- <dir class="m-0 flex items-center gap-3 p-0">
80
- <div class="flex gap-1">
81
- <ArrowUp
82
- onclick={() =>
83
- (sort = moveProperty(
84
- sort,
85
- fieldName,
86
- "up",
87
- ))}
88
- class="cursor-pointer hover:text-foreground"
89
- size="15"
90
- />
91
- <ArrowDown
92
- onclick={() =>
93
- (sort = moveProperty(
94
- sort,
95
- fieldName,
96
- "down",
97
- ))}
98
- class="cursor-pointer hover:text-foreground"
99
- size="15"
100
- />
101
- </div>
102
- <div class="text-foreground font-medium">
103
- {fieldName}
104
- </div>
105
- </dir>
106
- <dir class="m-0 flex items-center gap-2 p-0">
107
- <div class="flex items-center space-x-2">
108
- <Label class="text-xs">Ascending</Label>
109
- <!-- <Switch checked={true} /> -->
110
- <Switch
111
- bind:checked={
112
- () => sort[fieldName] === "asc",
113
- (v) =>
114
- (sort[fieldName] = v ? "asc" : "desc")
115
- }
116
- />
117
- </div>
118
- <Button
119
- onclick={() => delete sort[fieldName]}
120
- class="h-6 w-6 text-muted-foreground hover:bg-transparent"
121
- variant="ghost"
122
- size="icon"
123
- Icon={X}
124
- ></Button>
125
- </dir>
110
+ {#if items.length}
111
+ <div
112
+ class="flex flex-col gap-2"
113
+ use:dndzone={{
114
+ items,
115
+ dragDisabled: false,
116
+ dropTargetStyle: {},
117
+ flipDurationMs: 150,
118
+ }}
119
+ onconsider={handleDndConsider}
120
+ onfinalize={handleDndFinalize}
121
+ >
122
+ {#each items as rule (rule.id)}
123
+ {@const field = ctx.meta.collections[collectionName].fields[rule.id]}
124
+ <!-- Single wrapper so the `animate:flip` directive sits
125
+ directly under the keyed each. The conditional inside
126
+ swaps between the dnd shadow placeholder and the real
127
+ row. -->
128
+ <div animate:flip={{ duration: 150 }}>
129
+ {#if !field}
130
+ <div class="h-7"></div>
131
+ {:else}
132
+ {@const FieldIcon = getFieldIcon(ctx, rule.id, collectionName)}
133
+ <div class="flex items-center gap-2 text-xs">
134
+ <!-- Drag handle. The whole rule row is draggable via
135
+ dndzone, but cursor-grab on the handle telegraphs
136
+ the intent. -->
137
+ <GripVertical
138
+ size="15"
139
+ class="cursor-grab text-muted-foreground hover:text-foreground"
140
+ />
141
+
142
+ <!-- Field-name picker — only fields not used by other
143
+ rules are selectable; the current one always is. -->
144
+ <Select.Root
145
+ type="single"
146
+ value={rule.id}
147
+ onValueChange={(v) => v && renameField(rule.id, v)}
148
+ >
149
+ <Select.Trigger class="bg-muted h-7 flex-1 text-xs">
150
+ <div class="inline-flex items-center gap-1.5">
151
+ <FieldIcon size="13" />
152
+ {rule.id}
153
+ </div>
154
+ </Select.Trigger>
155
+ <Select.Content>
156
+ {#each getFieldOptionsForRule(rule.id) as optionName}
157
+ {@const OptionIcon = getFieldIcon(ctx, optionName, collectionName)}
158
+ <Select.Item value={optionName}>
159
+ <div class="inline-flex items-center gap-1.5">
160
+ <OptionIcon size="13" />
161
+ {optionName}
162
+ </div>
163
+ </Select.Item>
164
+ {/each}
165
+ </Select.Content>
166
+ </Select.Root>
167
+
168
+ <!-- Direction picker -->
169
+ <Select.Root
170
+ type="single"
171
+ value={rule.direction}
172
+ onValueChange={(v) => {
173
+ if (v === "asc" || v === "desc") setDirection(rule.id, v);
174
+ }}
175
+ >
176
+ <Select.Trigger class="bg-muted h-7 w-32 text-xs">
177
+ {rule.direction === "asc" ? "Ascending" : "Descending"}
178
+ </Select.Trigger>
179
+ <Select.Content>
180
+ <Select.Item value="asc">Ascending</Select.Item>
181
+ <Select.Item value="desc">Descending</Select.Item>
182
+ </Select.Content>
183
+ </Select.Root>
184
+
185
+ <Button
186
+ onclick={() => removeRule(rule.id)}
187
+ class="h-7 w-7 text-muted-foreground hover:bg-transparent"
188
+ variant="ghost"
189
+ size="icon"
190
+ Icon={X}
191
+ ></Button>
192
+ </div>
193
+ {/if}
126
194
  </div>
127
195
  {/each}
128
196
  </div>
@@ -144,25 +212,22 @@
144
212
  class={buttonVariants({
145
213
  variant: "ghost",
146
214
  size: "sm",
215
+ class: "text-muted-foreground",
147
216
  })}
148
217
  >
149
218
  <Plus />
150
219
  Add a sort rule
151
220
  </Popover.Trigger>
152
- <Popover.Content class="flex w-48 flex-col p-2">
153
- {#each getFieldNames() as fieldName}
154
- {@const FieldIcon = getFieldIcon(ctx, fieldName, collectionName)}
155
- <button
156
- onclick={() => {
157
- sort[fieldName] = "asc";
158
- popoverOpen = false;
159
- }}
160
- class="flex cursor-pointer items-center gap-2 rounded-md p-2 px-3 text-xs text-muted-foreground hover:bg-muted hover:text-foreground"
161
- >
162
- <FieldIcon size="15" />
163
- <div>{fieldName}</div>
164
- </button>
165
- {/each}
221
+ <Popover.Content class="w-64 p-2">
222
+ <FieldPicker
223
+ {collectionName}
224
+ excludeFields={items.map((r) => r.id)}
225
+ placeholder="Pick a field to sort by…"
226
+ onPick={(fieldName: string) => {
227
+ addRule(fieldName);
228
+ popoverOpen = false;
229
+ }}
230
+ />
166
231
  </Popover.Content>
167
232
  </Popover.Root>
168
233
  {:else}
@@ -1,8 +1,14 @@
1
1
  <script lang="ts">
2
+ // Sort button. Two-stage flow:
3
+ // • No sorts yet → typeahead field picker (fast path: just pick a
4
+ // field, it lands as asc, popover closes)
5
+ // • At least one sort → full multi-rule editor (Sort.svelte) so the
6
+ // user can reorder, toggle direction, add more, etc.
2
7
  import * as Popover from "../ui/popover/index.js";
3
- import { ArrowDownWideNarrow } from "lucide-svelte";
8
+ import { ArrowUpDown } from "lucide-svelte";
4
9
  import { buttonVariants } from "../ui/button";
5
10
  import Sort from "./sort.svelte";
11
+ import FieldPicker from "./fieldPicker.svelte";
6
12
 
7
13
  interface Props {
8
14
  collectionName: string;
@@ -11,26 +17,46 @@
11
17
  }
12
18
 
13
19
  let { collectionName, sort = $bindable({}), showText }: Props = $props();
20
+
21
+ let popoverOpen = $state(false);
22
+
23
+ function pick(fieldName: string) {
24
+ sort = { ...sort, [fieldName]: "asc" };
25
+ popoverOpen = false;
26
+ }
14
27
  </script>
15
28
 
16
- <Popover.Root>
29
+ <Popover.Root bind:open={popoverOpen}>
17
30
  <Popover.Trigger
18
31
  class={buttonVariants({
19
32
  variant: "ghost",
20
33
  size: "sm",
34
+ class: "text-muted-foreground",
21
35
  })}
22
36
  >
23
- <ArrowDownWideNarrow />
37
+ <ArrowUpDown />
24
38
  {#if showText}
25
39
  {@const sortRules = Object.keys(sort).length}
26
40
  {#if sortRules}
27
- Sorted by {sortRules} rules
41
+ Sorted by {sortRules} {sortRules === 1 ? "field" : "fields"}
28
42
  {:else}
29
43
  Sort
30
44
  {/if}
31
45
  {/if}
32
46
  </Popover.Trigger>
33
- <Popover.Content class="w-screen max-w-[20rem] p-0">
34
- <Sort {collectionName} {showText} bind:sort />
35
- </Popover.Content>
47
+ {#if Object.keys(sort).length === 0}
48
+ <!-- Fast path: typeahead picker. The first selected field starts
49
+ the sort; subsequent edits go through the full editor below. -->
50
+ <Popover.Content class="w-64 p-2">
51
+ <FieldPicker
52
+ {collectionName}
53
+ placeholder="Pick a field to sort by…"
54
+ onPick={pick}
55
+ />
56
+ </Popover.Content>
57
+ {:else}
58
+ <Popover.Content class="w-screen max-w-[24rem] p-0">
59
+ <Sort {collectionName} {showText} bind:sort />
60
+ </Popover.Content>
61
+ {/if}
36
62
  </Popover.Root>
@@ -1,6 +1,7 @@
1
1
  <script lang="ts" module>
2
2
  interface Column {
3
3
  id: string;
4
+ label?: string;
4
5
  icon?: any;
5
6
  subtext?: any;
6
7
  }
@@ -223,7 +224,7 @@
223
224
  {:else}
224
225
  <ColumnIcon size="12.5" class="text-muted-foreground" />
225
226
  {/if}
226
- <div class="font-bold whitespace-nowrap">{column.id}</div>
227
+ <div class="font-bold whitespace-nowrap">{column.label ?? column.id}</div>
227
228
  <div class="text-muted-foreground text-[0.7rem] whitespace-nowrap">
228
229
  {column.subtext}
229
230
  </div>
@@ -17,6 +17,11 @@
17
17
  title?: string;
18
18
  showHeader?: boolean;
19
19
  showFooter?: boolean;
20
+ // Popups are usually opened from chart drill-downs with a
21
+ // preset filter — re-filtering inside the popup almost never
22
+ // makes sense, so the filter button is hidden by default here.
23
+ // Pass showFilter={true} to surface it.
24
+ showFilter?: boolean;
20
25
  tableProps?: Partial<TableProps>;
21
26
  tabs?: CollectionTab[];
22
27
  view?: { id: string; [key: string]: any };
@@ -31,6 +36,7 @@
31
36
  title,
32
37
  showHeader = true,
33
38
  showFooter = true,
39
+ showFilter = false,
34
40
  tableProps,
35
41
  tabs,
36
42
  view,
@@ -78,6 +84,7 @@
78
84
  {searchParams}
79
85
  {showHeader}
80
86
  {showFooter}
87
+ {showFilter}
81
88
  {tableProps}
82
89
  {tabs}
83
90
  {view}