@startsimpli/ui 0.4.78 → 0.4.79

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,208 @@
1
+ 'use client';
2
+
3
+ /**
4
+ * ActivityFeedPanel — THE page-level activity composer (startsim-768w.19.5).
5
+ *
6
+ * The whole "what happened here" surface in one place: header, filter bar,
7
+ * day-grouped timeline, infinite scroll over the paginated endpoint, and three
8
+ * distinct nothing-to-show states (never recorded / filtered out / failed to
9
+ * load). An app supplies an injected client + its theme classNames and nothing
10
+ * else — rule #9 ships the composer, not just the primitive.
11
+ *
12
+ * CLIENT-INJECTED: the app passes `{ list: (p) => api.foundry.activity(slug, p) }`,
13
+ * which is what keeps @startsimpli/api out of packages/ui.
14
+ *
15
+ * THE UI MAKES NO VISIBILITY DECISIONS. There is no level check, no category
16
+ * vocabulary and no allowlist below this line. The endpoint already returned
17
+ * exactly the rows the signed-in user may see; a row that arrives is rendered,
18
+ * and an action string this build has never heard of renders with a humanised
19
+ * label. Filtering happens SERVER-SIDE (repo rule #8) and can only subtract.
20
+ */
21
+ import { ComponentType, ReactNode, useEffect, useMemo, useRef, useState } from 'react';
22
+ import { useInfiniteQuery } from '@tanstack/react-query';
23
+ import { Activity as ActivityIcon } from 'lucide-react';
24
+
25
+ import { ActivityTimeline } from '../ActivityTimeline';
26
+ import { ErrorState } from '../states';
27
+ import { ActivityFilterBar } from './ActivityFilterBar';
28
+ import {
29
+ deriveActivityFilterOptions,
30
+ toActivityTimelineItems,
31
+ } from './activity-events';
32
+ import type {
33
+ ActivityClient,
34
+ ActivityEvent,
35
+ ActivityFilterOption,
36
+ ActivityFilterValue,
37
+ } from './types';
38
+ import {
39
+ ACTIVITY_FEED_PANEL_DEFAULTS,
40
+ type ActivityFeedPanelClassNames,
41
+ } from './activity-feed-panel-default-class-names';
42
+
43
+ export interface ActivityFeedPanelProps {
44
+ client: ActivityClient;
45
+ /** Cache namespace, e.g. `['foundry', slug, 'activity']`. The live filters
46
+ * are appended, so invalidating the prefix refreshes every filtered view. */
47
+ queryKey?: readonly unknown[];
48
+ pageSize?: number;
49
+ refetchInterval?: number | false;
50
+ title?: ReactNode;
51
+ description?: ReactNode;
52
+ /** Rendered at the right of the header — e.g. the current state badge. */
53
+ headerAside?: ReactNode;
54
+ /** Given every loaded row, so a host can surface an invariant (e.g. "the
55
+ * newest status event disagrees with the state badge"). */
56
+ renderFooter?: (events: ActivityEvent[]) => ReactNode;
57
+ showFilters?: boolean;
58
+ emptyTitle?: string;
59
+ emptyDescription?: string;
60
+ filteredEmptyTitle?: string;
61
+ filteredEmptyDescription?: string;
62
+ errorTitle?: string;
63
+ activityIcons?: Record<string, ComponentType<any>>;
64
+ activityColors?: Record<string, string>;
65
+ defaultIcon?: ComponentType<any>;
66
+ classNames?: ActivityFeedPanelClassNames;
67
+ }
68
+
69
+ /** Typing shouldn't fire a request per keystroke; every other control should
70
+ * apply at once. */
71
+ function useDebounced<T>(value: T, ms: number): T {
72
+ const [settled, setSettled] = useState(value);
73
+ useEffect(() => {
74
+ const t = setTimeout(() => setSettled(value), ms);
75
+ return () => clearTimeout(t);
76
+ }, [value, ms]);
77
+ return settled;
78
+ }
79
+
80
+ /**
81
+ * Options accumulate across pages and across filtered views: once a person or
82
+ * an event type has been seen, it stays offerable, so narrowing to one action
83
+ * does not empty the picker you narrowed with. Still derived only from rows the
84
+ * user actually received.
85
+ */
86
+ function useStickyOptions(events: ActivityEvent[]) {
87
+ const seen = useRef({ actors: new Map<string, string>(), actions: new Map<string, string>() });
88
+ return useMemo(() => {
89
+ const fresh = deriveActivityFilterOptions(events);
90
+ fresh.actors.forEach((o) => seen.current.actors.set(o.value, o.label));
91
+ fresh.actions.forEach((o) => seen.current.actions.set(o.value, o.label));
92
+ const sort = (m: Map<string, string>): ActivityFilterOption[] =>
93
+ [...m].map(([value, label]) => ({ value, label })).sort((a, b) => a.label.localeCompare(b.label));
94
+ return { actors: sort(seen.current.actors), actions: sort(seen.current.actions) };
95
+ }, [events]);
96
+ }
97
+
98
+ export function ActivityFeedPanel({
99
+ client,
100
+ queryKey = ['activity'],
101
+ pageSize = 25,
102
+ refetchInterval = false,
103
+ title = 'Activity',
104
+ description = 'Everything that happened here, newest first — as much as your access level allows.',
105
+ headerAside,
106
+ renderFooter,
107
+ showFilters = true,
108
+ emptyTitle = 'Nothing recorded yet',
109
+ emptyDescription = 'Actions taken here will show up in this list.',
110
+ filteredEmptyTitle = 'No activity matches these filters',
111
+ filteredEmptyDescription = 'Widen the date range or clear a filter to see more.',
112
+ errorTitle = 'Could not load activity',
113
+ activityIcons,
114
+ activityColors,
115
+ defaultIcon = ActivityIcon,
116
+ classNames = {},
117
+ }: ActivityFeedPanelProps) {
118
+ const cx = { ...ACTIVITY_FEED_PANEL_DEFAULTS, ...classNames };
119
+
120
+ const [filters, setFilters] = useState<ActivityFilterValue>({});
121
+ const debouncedQ = useDebounced(filters.q ?? '', 300);
122
+
123
+ // What actually goes to the server. `q` lags the input by a beat; everything
124
+ // else applies immediately.
125
+ const params = useMemo(() => {
126
+ const { q: _typed, ...rest } = filters;
127
+ return debouncedQ ? { ...rest, q: debouncedQ } : rest;
128
+ }, [filters, debouncedQ]);
129
+
130
+ const query = useInfiniteQuery({
131
+ queryKey: [...queryKey, params],
132
+ queryFn: ({ pageParam }) => client.list({ ...params, page: pageParam as number, pageSize }),
133
+ initialPageParam: 1,
134
+ getNextPageParam: (last, all) => (last?.next ? all.length + 1 : undefined),
135
+ refetchInterval,
136
+ });
137
+
138
+ const events = useMemo(
139
+ () => (query.data?.pages ?? []).flatMap((p) => p?.results ?? []),
140
+ [query.data],
141
+ );
142
+ const items = useMemo(() => toActivityTimelineItems(events), [events]);
143
+ const options = useStickyOptions(events);
144
+
145
+ const isFiltered = Object.keys(filters).length > 0;
146
+ const total = query.data?.pages?.[0]?.count ?? events.length;
147
+
148
+ const loadMore = () => {
149
+ if (query.hasNextPage && !query.isFetchingNextPage) query.fetchNextPage();
150
+ };
151
+
152
+ return (
153
+ <section className={cx.root} data-testid="activity-feed-panel">
154
+ <div className={cx.header}>
155
+ <div className={cx.heading}>
156
+ <h2 className={cx.title}>{title}</h2>
157
+ {description && <p className={cx.description}>{description}</p>}
158
+ </div>
159
+ {headerAside}
160
+ </div>
161
+
162
+ {showFilters && (
163
+ <div className={cx.filters}>
164
+ <ActivityFilterBar
165
+ value={filters}
166
+ onChange={setFilters}
167
+ actors={options.actors}
168
+ actions={options.actions}
169
+ classNames={cx.filterBar}
170
+ />
171
+ </div>
172
+ )}
173
+
174
+ <div className={cx.body}>
175
+ {query.isError ? (
176
+ <ErrorState
177
+ title={errorTitle}
178
+ message={(query.error as Error)?.message || 'Please try again.'}
179
+ onRetry={() => query.refetch()}
180
+ />
181
+ ) : (
182
+ <ActivityTimeline
183
+ activities={items}
184
+ loading={query.isLoading}
185
+ groupByDay
186
+ autoLoadMore
187
+ hasMore={!!query.hasNextPage}
188
+ onLoadMore={loadMore}
189
+ emptyTitle={isFiltered ? filteredEmptyTitle : emptyTitle}
190
+ emptyDescription={isFiltered ? filteredEmptyDescription : emptyDescription}
191
+ activityIcons={activityIcons}
192
+ activityColors={activityColors}
193
+ defaultIcon={defaultIcon}
194
+ loadMoreLabel={query.isFetchingNextPage ? 'Loading…' : 'Load more'}
195
+ />
196
+ )}
197
+ </div>
198
+
199
+ {events.length > 0 && total > events.length && (
200
+ <p className={cx.countLine}>
201
+ Showing {events.length} of {total} events.
202
+ </p>
203
+ )}
204
+
205
+ {renderFooter?.(events)}
206
+ </section>
207
+ );
208
+ }
@@ -0,0 +1,180 @@
1
+ 'use client';
2
+
3
+ /**
4
+ * ActivityFilterBar — narrow an event stream by person, event type and date.
5
+ *
6
+ * Two invariants:
7
+ * 1. FILTERS SUBTRACT ONLY. Every change is handed straight to the server as
8
+ * a query param; nothing here hides a row the caller received, and asking
9
+ * for something the caller's level cannot see returns an empty page rather
10
+ * than a hint that it exists.
11
+ * 2. THE OPTIONS ARE DERIVED, never declared. `actors` / `actions` come from
12
+ * `deriveActivityFilterOptions(rowsTheUserReceived)`, so a new event type
13
+ * appears in the picker on its own.
14
+ */
15
+ import { X } from 'lucide-react';
16
+ import type { ActivityFilterOption, ActivityFilterValue } from './types';
17
+ import {
18
+ ACTIVITY_FILTER_BAR_DEFAULTS,
19
+ type ActivityFilterBarClassNames,
20
+ } from './activity-filter-bar-default-class-names';
21
+
22
+ export interface ActivityFilterBarProps {
23
+ value: ActivityFilterValue;
24
+ onChange: (next: ActivityFilterValue) => void;
25
+ /** Derived from the rows the user received — never a hardcoded list. */
26
+ actors?: ActivityFilterOption[];
27
+ actions?: ActivityFilterOption[];
28
+ searchPlaceholder?: string;
29
+ anyActorLabel?: string;
30
+ anyActionLabel?: string;
31
+ classNames?: ActivityFilterBarClassNames;
32
+ }
33
+
34
+ type FilterKey = keyof ActivityFilterValue;
35
+
36
+ /** Drop empty keys so an "unfiltered" value is `{}` — the thing the panel and
37
+ * the query key compare against. */
38
+ function prune(value: ActivityFilterValue): ActivityFilterValue {
39
+ const out: ActivityFilterValue = {};
40
+ for (const [k, v] of Object.entries(value)) {
41
+ if (typeof v === 'string' && v.trim()) out[k as FilterKey] = v;
42
+ }
43
+ return out;
44
+ }
45
+
46
+ /** `2026-07-20` -> the instant the day starts / ends, in ISO-8601, because the
47
+ * server bounds on a timestamp. A bare date would silently exclude the whole
48
+ * of the `until` day. */
49
+ function dayStart(date: string): string {
50
+ return `${date}T00:00:00.000Z`;
51
+ }
52
+ function dayEnd(date: string): string {
53
+ return `${date}T23:59:59.999Z`;
54
+ }
55
+ /** ISO instant -> the `YYYY-MM-DD` a date input wants back. */
56
+ function asDateInput(iso?: string): string {
57
+ return iso ? iso.slice(0, 10) : '';
58
+ }
59
+
60
+ /** Show the DAY THE USER PICKED. The stored value is a UTC day boundary, so
61
+ * handing it straight to `new Date()` and formatting locally shifts it back a
62
+ * day west of Greenwich — the chip would contradict the date input above it. */
63
+ function shortDate(iso: string): string {
64
+ const d = new Date(`${iso.slice(0, 10)}T12:00:00`);
65
+ return Number.isNaN(d.getTime())
66
+ ? iso
67
+ : d.toLocaleDateString(undefined, { month: 'short', day: 'numeric', year: 'numeric' });
68
+ }
69
+
70
+ export function activityFilterCount(value: ActivityFilterValue): number {
71
+ return Object.keys(prune(value)).length;
72
+ }
73
+
74
+ export function ActivityFilterBar({
75
+ value,
76
+ onChange,
77
+ actors = [],
78
+ actions = [],
79
+ searchPlaceholder = 'Search activity…',
80
+ anyActorLabel = 'Anyone',
81
+ anyActionLabel = 'All events',
82
+ classNames = {},
83
+ }: ActivityFilterBarProps) {
84
+ const cx = { ...ACTIVITY_FILTER_BAR_DEFAULTS, ...classNames };
85
+
86
+ const set = (key: FilterKey, next: string | undefined) =>
87
+ onChange(prune({ ...value, [key]: next ?? '' }));
88
+
89
+ const labelFor = (options: ActivityFilterOption[], v: string) =>
90
+ options.find((o) => o.value === v)?.label ?? v;
91
+
92
+ const chips: Array<{ key: FilterKey; name: string; text: string }> = [];
93
+ if (value.actor) chips.push({ key: 'actor', name: 'person', text: `Person: ${labelFor(actors, value.actor)}` });
94
+ if (value.action) chips.push({ key: 'action', name: 'event', text: `Event: ${labelFor(actions, value.action)}` });
95
+ if (value.since) chips.push({ key: 'since', name: 'from date', text: `From ${shortDate(value.since)}` });
96
+ if (value.until) chips.push({ key: 'until', name: 'to date', text: `To ${shortDate(value.until)}` });
97
+ if (value.q) chips.push({ key: 'q', name: 'search', text: `Search: ${value.q}` });
98
+
99
+ return (
100
+ <div className={cx.root} data-testid="activity-filter-bar">
101
+ <div className={cx.controls}>
102
+ <input
103
+ type="search"
104
+ aria-label="Search activity"
105
+ placeholder={searchPlaceholder}
106
+ className={cx.search}
107
+ value={value.q ?? ''}
108
+ onChange={(e) => set('q', e.target.value)}
109
+ />
110
+
111
+ <select
112
+ aria-label="Filter by person"
113
+ className={cx.select}
114
+ value={value.actor ?? ''}
115
+ onChange={(e) => set('actor', e.target.value)}
116
+ >
117
+ <option value="">{anyActorLabel}</option>
118
+ {actors.map((o) => (
119
+ <option key={o.value} value={o.value}>
120
+ {o.label}
121
+ </option>
122
+ ))}
123
+ </select>
124
+
125
+ <select
126
+ aria-label="Filter by event"
127
+ className={cx.select}
128
+ value={value.action ?? ''}
129
+ onChange={(e) => set('action', e.target.value)}
130
+ >
131
+ <option value="">{anyActionLabel}</option>
132
+ {actions.map((o) => (
133
+ <option key={o.value} value={o.value}>
134
+ {o.label}
135
+ </option>
136
+ ))}
137
+ </select>
138
+
139
+ <div className={cx.dateGroup}>
140
+ <input
141
+ type="date"
142
+ aria-label="From date"
143
+ className={cx.date}
144
+ value={asDateInput(value.since)}
145
+ onChange={(e) => set('since', e.target.value ? dayStart(e.target.value) : '')}
146
+ />
147
+ <span className={cx.dateSeparator}>to</span>
148
+ <input
149
+ type="date"
150
+ aria-label="To date"
151
+ className={cx.date}
152
+ value={asDateInput(value.until)}
153
+ onChange={(e) => set('until', e.target.value ? dayEnd(e.target.value) : '')}
154
+ />
155
+ </div>
156
+ </div>
157
+
158
+ {chips.length > 0 && (
159
+ <div className={cx.chips} data-testid="activity-active-filters">
160
+ {chips.map((chip) => (
161
+ <span key={chip.key} className={cx.chip}>
162
+ {chip.text}
163
+ <button
164
+ type="button"
165
+ aria-label={`Remove filter: ${chip.name}`}
166
+ className={cx.chipRemove}
167
+ onClick={() => set(chip.key, '')}
168
+ >
169
+ <X className="h-3 w-3" />
170
+ </button>
171
+ </span>
172
+ ))}
173
+ <button type="button" className={cx.clearAll} onClick={() => onChange({})}>
174
+ Clear all
175
+ </button>
176
+ </div>
177
+ )}
178
+ </div>
179
+ );
180
+ }