Seth commited on
Commit
401b968
·
1 Parent(s): 6953c2b
frontend/src/components/workspace/MainTableWorkspace.jsx CHANGED
@@ -15,6 +15,8 @@ export default function MainTableWorkspace({
15
  search = null,
16
  right = null,
17
  filters = null,
 
 
18
  sectionIcon: SectionIcon,
19
  sectionTitle,
20
  sectionCount,
@@ -93,7 +95,7 @@ export default function MainTableWorkspace({
93
  </div>
94
  </div>
95
 
96
- {filters && (
97
  <div className="rounded-xl border border-slate-200 bg-white p-4 shadow-sm">{filters}</div>
98
  )}
99
 
@@ -115,12 +117,23 @@ export default function MainTableWorkspace({
115
  </span>
116
  </button>
117
 
118
- {sectionOpen && (
119
- <>
120
- {tableToolbar}
121
- {children}
122
- </>
123
- )}
 
 
 
 
 
 
 
 
 
 
 
124
  </div>
125
  </div>
126
  );
 
15
  search = null,
16
  right = null,
17
  filters = null,
18
+ /** 'top' = full-width strip above the table card; 'left' = narrow rail beside the table inside the card */
19
+ filtersPlacement = 'top',
20
  sectionIcon: SectionIcon,
21
  sectionTitle,
22
  sectionCount,
 
95
  </div>
96
  </div>
97
 
98
+ {filtersPlacement === 'top' && filters && (
99
  <div className="rounded-xl border border-slate-200 bg-white p-4 shadow-sm">{filters}</div>
100
  )}
101
 
 
117
  </span>
118
  </button>
119
 
120
+ {sectionOpen &&
121
+ (filtersPlacement === 'left' && filters ? (
122
+ <div className="flex flex-col gap-3 border-t border-slate-100 px-2 pb-3 pt-2 sm:px-4 md:flex-row md:items-start md:gap-3">
123
+ <aside className="w-full shrink-0 rounded-lg border border-slate-200 bg-slate-50/80 p-2.5 md:w-52 md:max-w-[13rem] overflow-y-auto max-h-[min(70vh,720px)]">
124
+ {filters}
125
+ </aside>
126
+ <div className="flex min-w-0 flex-1 flex-col gap-2">
127
+ {tableToolbar}
128
+ {children}
129
+ </div>
130
+ </div>
131
+ ) : (
132
+ <>
133
+ {tableToolbar}
134
+ {children}
135
+ </>
136
+ ))}
137
  </div>
138
  </div>
139
  );
frontend/src/pages/Contacts.jsx CHANGED
@@ -48,7 +48,7 @@ export default function Contacts() {
48
  const [seqLoading, setSeqLoading] = useState(false);
49
  const [searchQuery, setSearchQuery] = useState('');
50
  const [filterRows, setFilterRows] = useState(() => [makeFilterRow()]);
51
- const [demoBusy, setDemoBusy] = useState(false);
52
  const [sortBy, setSortBy] = useState('created_at');
53
  const [sortDir, setSortDir] = useState('desc');
54
  const [page, setPage] = useState(1);
@@ -221,7 +221,7 @@ export default function Contacts() {
221
  };
222
 
223
  const seedDemoContacts = async () => {
224
- setDemoBusy(true);
225
  try {
226
  const res = await fetch('/api/contacts/seed-demo', { method: 'POST' });
227
  const data = await res.json().catch(() => ({}));
@@ -230,15 +230,11 @@ export default function Contacts() {
230
  }
231
  await fetchFields();
232
  await fetchContacts();
233
- const n = data.inserted ?? 0;
234
- if (n > 0) {
235
- alert(`Loaded ${n} demo contact${n === 1 ? '' : 's'} with varied Apollo-style fields.`);
236
- }
237
  } catch (e) {
238
  console.error(e);
239
  alert(e.message || 'Could not load demo data');
240
  } finally {
241
- setDemoBusy(false);
242
  }
243
  };
244
 
@@ -572,30 +568,36 @@ export default function Contacts() {
572
  };
573
 
574
  const filtersBlock = (
575
- <div className="space-y-3">
576
- <div className="flex flex-wrap items-center justify-between gap-2">
577
- <div className="flex items-center text-xs text-slate-500 gap-1.5 font-medium">
578
- <SlidersHorizontal className="h-3.5 w-3.5" />
579
- Filter by any Apollo field (combined with AND)
580
  </div>
581
- <Button type="button" variant="outline" size="sm" className="gap-1" onClick={addFilterRow}>
 
 
 
 
 
 
582
  <Plus className="h-3.5 w-3.5" />
583
  Add filter
584
  </Button>
585
  </div>
586
- <div className="space-y-3">
587
  {filterRows.map((row) => (
588
  <div
589
  key={row.id}
590
- className="grid grid-cols-1 gap-3 lg:grid-cols-12 lg:items-end lg:gap-2 rounded-xl border border-slate-100 bg-slate-50/50 p-3"
591
  >
592
- <div className="lg:col-span-3">
593
  <label className="sr-only">Field</label>
594
  <Select
595
  value={row.field}
596
  onValueChange={(v) => updateFilterRow(row.id, { field: v })}
597
  >
598
- <SelectTrigger className="border-slate-200 bg-white">
599
  <SelectValue placeholder="Field" />
600
  </SelectTrigger>
601
  <SelectContent>
@@ -610,13 +612,13 @@ export default function Contacts() {
610
  </div>
611
  {row.field !== 'none' && (
612
  <>
613
- <div className="lg:col-span-2">
614
  <label className="sr-only">Operator</label>
615
  <Select
616
  value={row.op}
617
  onValueChange={(v) => updateFilterRow(row.id, { op: v })}
618
  >
619
- <SelectTrigger className="border-slate-200 bg-white">
620
  <SelectValue />
621
  </SelectTrigger>
622
  <SelectContent>
@@ -629,21 +631,19 @@ export default function Contacts() {
629
  </Select>
630
  </div>
631
  {(row.op === 'contains' || row.op === 'equals') && (
632
- <div className="lg:col-span-6">
633
- <Input
634
- className="border-slate-200 bg-white"
635
- placeholder={`Value for “${row.field}”`}
636
- value={row.value}
637
- onChange={(e) =>
638
- updateFilterRow(row.id, { value: e.target.value })
639
- }
640
- />
641
- </div>
642
  )}
643
  {(row.op === 'from' || row.op === 'to' || row.op === 'between') && (
644
- <div className="flex gap-2 lg:col-span-6">
645
  <Input
646
- className="border-slate-200 bg-white flex-1"
647
  placeholder="From"
648
  value={row.fromVal}
649
  onChange={(e) =>
@@ -651,7 +651,7 @@ export default function Contacts() {
651
  }
652
  />
653
  <Input
654
- className="border-slate-200 bg-white flex-1"
655
  placeholder="To"
656
  value={row.toVal}
657
  onChange={(e) =>
@@ -660,16 +660,16 @@ export default function Contacts() {
660
  />
661
  </div>
662
  )}
663
- <div className="flex lg:col-span-1 lg:justify-end">
664
  <Button
665
  type="button"
666
  variant="ghost"
667
  size="icon"
668
- className="text-slate-400 hover:text-red-600"
669
  aria-label="Remove filter row"
670
  onClick={() => removeFilterRow(row.id)}
671
  >
672
- <Trash2 className="h-4 w-4" />
673
  </Button>
674
  </div>
675
  </>
@@ -696,16 +696,16 @@ export default function Contacts() {
696
  <div className="flex flex-wrap items-center gap-2 justify-end">
697
  <Button
698
  type="button"
699
- variant="outline"
700
  size="sm"
701
  onClick={seedDemoContacts}
702
- disabled={demoBusy}
703
- className="gap-1.5"
704
  >
705
- {demoBusy ? (
706
  <Loader2 className="h-4 w-4 animate-spin shrink-0" aria-hidden />
707
  ) : null}
708
- Load demo data
709
  </Button>
710
  <Button variant="outline" size="sm" onClick={() => fetchContacts()}>
711
  Refresh
@@ -713,6 +713,7 @@ export default function Contacts() {
713
  </div>
714
  }
715
  filters={filtersBlock}
 
716
  sectionIcon={Users}
717
  sectionTitle="All contacts"
718
  sectionCount={total}
@@ -732,15 +733,15 @@ export default function Contacts() {
732
  <div className="flex flex-wrap items-center justify-center gap-2">
733
  <Button
734
  type="button"
735
- variant="outline"
736
  size="sm"
737
  onClick={seedDemoContacts}
738
- disabled={demoBusy}
739
  >
740
- {demoBusy ? (
741
  <Loader2 className="h-4 w-4 animate-spin mr-2" aria-hidden />
742
  ) : null}
743
- Load demo data
744
  </Button>
745
  <Button variant="outline" size="sm" onClick={beginAddContact}>
746
  New contact
 
48
  const [seqLoading, setSeqLoading] = useState(false);
49
  const [searchQuery, setSearchQuery] = useState('');
50
  const [filterRows, setFilterRows] = useState(() => [makeFilterRow()]);
51
+ const [seedBusy, setSeedBusy] = useState(false);
52
  const [sortBy, setSortBy] = useState('created_at');
53
  const [sortDir, setSortDir] = useState('desc');
54
  const [page, setPage] = useState(1);
 
221
  };
222
 
223
  const seedDemoContacts = async () => {
224
+ setSeedBusy(true);
225
  try {
226
  const res = await fetch('/api/contacts/seed-demo', { method: 'POST' });
227
  const data = await res.json().catch(() => ({}));
 
230
  }
231
  await fetchFields();
232
  await fetchContacts();
 
 
 
 
233
  } catch (e) {
234
  console.error(e);
235
  alert(e.message || 'Could not load demo data');
236
  } finally {
237
+ setSeedBusy(false);
238
  }
239
  };
240
 
 
568
  };
569
 
570
  const filtersBlock = (
571
+ <div className="space-y-2 text-xs">
572
+ <div className="space-y-2">
573
+ <div className="flex items-start gap-1.5 text-slate-600 font-medium leading-snug">
574
+ <SlidersHorizontal className="h-3.5 w-3.5 mt-0.5 shrink-0" />
575
+ <span>Filters (AND)</span>
576
  </div>
577
+ <Button
578
+ type="button"
579
+ variant="outline"
580
+ size="sm"
581
+ className="h-8 w-full gap-1 text-xs"
582
+ onClick={addFilterRow}
583
+ >
584
  <Plus className="h-3.5 w-3.5" />
585
  Add filter
586
  </Button>
587
  </div>
588
+ <div className="space-y-2">
589
  {filterRows.map((row) => (
590
  <div
591
  key={row.id}
592
+ className="space-y-2 rounded-lg border border-slate-200 bg-white/90 p-2 shadow-sm"
593
  >
594
+ <div>
595
  <label className="sr-only">Field</label>
596
  <Select
597
  value={row.field}
598
  onValueChange={(v) => updateFilterRow(row.id, { field: v })}
599
  >
600
+ <SelectTrigger className="h-8 border-slate-200 bg-white text-xs">
601
  <SelectValue placeholder="Field" />
602
  </SelectTrigger>
603
  <SelectContent>
 
612
  </div>
613
  {row.field !== 'none' && (
614
  <>
615
+ <div>
616
  <label className="sr-only">Operator</label>
617
  <Select
618
  value={row.op}
619
  onValueChange={(v) => updateFilterRow(row.id, { op: v })}
620
  >
621
+ <SelectTrigger className="h-8 border-slate-200 bg-white text-xs">
622
  <SelectValue />
623
  </SelectTrigger>
624
  <SelectContent>
 
631
  </Select>
632
  </div>
633
  {(row.op === 'contains' || row.op === 'equals') && (
634
+ <Input
635
+ className="h-8 border-slate-200 bg-white text-xs"
636
+ placeholder={`“${row.field}”`}
637
+ value={row.value}
638
+ onChange={(e) =>
639
+ updateFilterRow(row.id, { value: e.target.value })
640
+ }
641
+ />
 
 
642
  )}
643
  {(row.op === 'from' || row.op === 'to' || row.op === 'between') && (
644
+ <div className="flex flex-col gap-2">
645
  <Input
646
+ className="h-8 border-slate-200 bg-white text-xs"
647
  placeholder="From"
648
  value={row.fromVal}
649
  onChange={(e) =>
 
651
  }
652
  />
653
  <Input
654
+ className="h-8 border-slate-200 bg-white text-xs"
655
  placeholder="To"
656
  value={row.toVal}
657
  onChange={(e) =>
 
660
  />
661
  </div>
662
  )}
663
+ <div className="flex justify-end pt-0.5">
664
  <Button
665
  type="button"
666
  variant="ghost"
667
  size="icon"
668
+ className="h-8 w-8 text-slate-400 hover:text-red-600"
669
  aria-label="Remove filter row"
670
  onClick={() => removeFilterRow(row.id)}
671
  >
672
+ <Trash2 className="h-3.5 w-3.5" />
673
  </Button>
674
  </div>
675
  </>
 
696
  <div className="flex flex-wrap items-center gap-2 justify-end">
697
  <Button
698
  type="button"
699
+ variant="secondary"
700
  size="sm"
701
  onClick={seedDemoContacts}
702
+ disabled={seedBusy}
703
+ title="Insert sample Apollo-style contacts for preview"
704
  >
705
+ {seedBusy ? (
706
  <Loader2 className="h-4 w-4 animate-spin shrink-0" aria-hidden />
707
  ) : null}
708
+ Demo data
709
  </Button>
710
  <Button variant="outline" size="sm" onClick={() => fetchContacts()}>
711
  Refresh
 
713
  </div>
714
  }
715
  filters={filtersBlock}
716
+ filtersPlacement="left"
717
  sectionIcon={Users}
718
  sectionTitle="All contacts"
719
  sectionCount={total}
 
733
  <div className="flex flex-wrap items-center justify-center gap-2">
734
  <Button
735
  type="button"
736
+ variant="secondary"
737
  size="sm"
738
  onClick={seedDemoContacts}
739
+ disabled={seedBusy}
740
  >
741
+ {seedBusy ? (
742
  <Loader2 className="h-4 w-4 animate-spin mr-2" aria-hidden />
743
  ) : null}
744
+ Load demo rows (preview UI)
745
  </Button>
746
  <Button variant="outline" size="sm" onClick={beginAddContact}>
747
  New contact