@colixsystems/widget-sdk 0.202.0 → 0.205.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.
- package/README.md +37 -1
- package/dist/contract.cjs +128 -3
- package/dist/contract.js +128 -3
- package/dist/hooks.js +23 -21
- package/dist/index.d.ts +149 -0
- package/dist/index.js +7 -0
- package/dist/index.native.js +7 -0
- package/dist/linter.cjs +6 -2
- package/dist/linter.js +6 -2
- package/dist/loading-tokens.js +122 -0
- package/dist/loading-view.js +330 -0
- package/dist/loading.js +7 -0
- package/dist/loading.native.js +10 -0
- package/dist/stats-hooks.js +144 -0
- package/package.json +1 -1
- package/src/contract.js +128 -3
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
// sc-10691 — read-only hooks for an app's user totals and user activity. Each
|
|
2
|
+
// is one keyed read, stale-while-revalidate like useDatastoreQuery, and each
|
|
3
|
+
// re-runs on the page's pull-to-refresh.
|
|
4
|
+
import { useCallback, useEffect, useRef, useState } from "react";
|
|
5
|
+
import { toDirectoryError, useRefresh, useWidgetContextOrThrow } from "./hooks.js";
|
|
6
|
+
|
|
7
|
+
const EMPTY = { identity: null, key: null, data: null, error: null };
|
|
8
|
+
|
|
9
|
+
function serialize(value) {
|
|
10
|
+
try {
|
|
11
|
+
return JSON.stringify(value === undefined ? null : value);
|
|
12
|
+
} catch (_e) {
|
|
13
|
+
return null;
|
|
14
|
+
}
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* Runs `read()` whenever `key` changes. A changed key on the same `identity`
|
|
19
|
+
* keeps the previous data on screen (`fetching`); a new identity starts over
|
|
20
|
+
* (`loading`). A null key is "nothing to read" and settles empty.
|
|
21
|
+
*/
|
|
22
|
+
function useKeyedRead(read, identity, key, mapError) {
|
|
23
|
+
const readRef = useRef(read);
|
|
24
|
+
readRef.current = read;
|
|
25
|
+
const runRef = useRef(0);
|
|
26
|
+
const [settled, setSettled] = useState(EMPTY);
|
|
27
|
+
const [fetching, setFetching] = useState(key !== null);
|
|
28
|
+
|
|
29
|
+
const run = useCallback(async () => {
|
|
30
|
+
const myRun = ++runRef.current;
|
|
31
|
+
if (key === null) {
|
|
32
|
+
setSettled(EMPTY);
|
|
33
|
+
setFetching(false);
|
|
34
|
+
return;
|
|
35
|
+
}
|
|
36
|
+
setFetching(true);
|
|
37
|
+
try {
|
|
38
|
+
const data = await readRef.current();
|
|
39
|
+
if (runRef.current === myRun) setSettled({ identity, key, data, error: null });
|
|
40
|
+
} catch (err) {
|
|
41
|
+
if (runRef.current !== myRun) return;
|
|
42
|
+
setSettled((prev) => ({
|
|
43
|
+
identity,
|
|
44
|
+
key,
|
|
45
|
+
data: prev.identity === identity ? prev.data : null,
|
|
46
|
+
error: mapError(err),
|
|
47
|
+
}));
|
|
48
|
+
} finally {
|
|
49
|
+
if (runRef.current === myRun) setFetching(false);
|
|
50
|
+
}
|
|
51
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
52
|
+
}, [identity, key]);
|
|
53
|
+
|
|
54
|
+
useEffect(() => {
|
|
55
|
+
run();
|
|
56
|
+
}, [run]);
|
|
57
|
+
useRefresh(run);
|
|
58
|
+
|
|
59
|
+
const sameIdentity = settled.identity === identity;
|
|
60
|
+
return {
|
|
61
|
+
data: sameIdentity ? settled.data : null,
|
|
62
|
+
loading: key !== null && !sameIdentity,
|
|
63
|
+
fetching,
|
|
64
|
+
error: sameIdentity ? settled.error : null,
|
|
65
|
+
refetch: run,
|
|
66
|
+
};
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* The app's user totals for its own admins — never a list of people.
|
|
71
|
+
* `query`: `{ days?: 7 | 30 | 90, group_id? }`. Returns `{ stats, loading,
|
|
72
|
+
* fetching, error, refetch }`; `stats.invites` is null unless the viewer may
|
|
73
|
+
* list invites. A viewer without the admin user view gets a FORBIDDEN error.
|
|
74
|
+
*/
|
|
75
|
+
export function useUserStats(query) {
|
|
76
|
+
const ctx = useWidgetContextOrThrow("useUserStats");
|
|
77
|
+
if (!ctx.directory || !ctx.directory.users || typeof ctx.directory.users.stats !== "function") {
|
|
78
|
+
throw new Error("useUserStats: host did not inject a directory client (ctx.directory.users.stats)");
|
|
79
|
+
}
|
|
80
|
+
const users = ctx.directory.users;
|
|
81
|
+
const key = serialize(query);
|
|
82
|
+
const { data, ...state } = useKeyedRead(() => users.stats(query), "stats", key, toDirectoryError);
|
|
83
|
+
return { ...state, stats: data };
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
function useUsersNamespace(hookName, method) {
|
|
87
|
+
const ctx = useWidgetContextOrThrow(hookName);
|
|
88
|
+
const users = ctx.directory && ctx.directory.users;
|
|
89
|
+
if (!users || typeof users[method] !== "function") {
|
|
90
|
+
throw new Error(`${hookName}: host did not inject a directory client (ctx.directory.users.${method})`);
|
|
91
|
+
}
|
|
92
|
+
return users;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/**
|
|
96
|
+
* sc-10691 — user activity for an app's admins: who is online now, active
|
|
97
|
+
* in the last day / week / month, never active, active users per bucket,
|
|
98
|
+
* the platform split and the active share per group. `query`: `{ days?: 7 |
|
|
99
|
+
* 30 | 90, group_id? }`. `error.reason` is "ACTIVITY_DISABLED" when the
|
|
100
|
+
* workspace does not record activity, "ACTIVITY_UNAVAILABLE" before it is
|
|
101
|
+
* offered to the workspace, and `error.code` FORBIDDEN without the
|
|
102
|
+
* users.activity permission. Pass `null` to read nothing.
|
|
103
|
+
*/
|
|
104
|
+
export function useUserActivity(query) {
|
|
105
|
+
const users = useUsersNamespace("useUserActivity", "activity");
|
|
106
|
+
const key = query === null ? null : serialize(query);
|
|
107
|
+
const { data, ...state } = useKeyedRead(() => users.activity(query), "activity", key, toDirectoryError);
|
|
108
|
+
return { ...state, activity: data };
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/**
|
|
112
|
+
* sc-10691 — one page of users not seen for `inactive_days` (default 30),
|
|
113
|
+
* never-seen first. `query`: `{ inactive_days?, group_id?, limit?, offset? }`.
|
|
114
|
+
* Returns `{ users, total, loading, fetching, error, refetch, remind }`; each
|
|
115
|
+
* user is `{ id, name, last_active_at, last_reminded_at }` (last_active_at
|
|
116
|
+
* null when never seen). `remind(id, { title, body })` notifies one and
|
|
117
|
+
* re-reads the page. Same gate as useUserActivity.
|
|
118
|
+
*/
|
|
119
|
+
export function useInactiveUsers(query) {
|
|
120
|
+
const users = useUsersNamespace("useInactiveUsers", "inactive");
|
|
121
|
+
const key = query === null ? null : serialize(query);
|
|
122
|
+
const { data, ...state } = useKeyedRead(() => users.inactive(query), "inactive", key, toDirectoryError);
|
|
123
|
+
const { refetch } = state;
|
|
124
|
+
const remind = useCallback(
|
|
125
|
+
async (id, message) => {
|
|
126
|
+
if (typeof users.remind !== "function") {
|
|
127
|
+
throw new Error("useInactiveUsers: host did not inject ctx.directory.users.remind");
|
|
128
|
+
}
|
|
129
|
+
try {
|
|
130
|
+
await users.remind(id, message);
|
|
131
|
+
} catch (err) {
|
|
132
|
+
throw toDirectoryError(err);
|
|
133
|
+
}
|
|
134
|
+
await refetch();
|
|
135
|
+
},
|
|
136
|
+
[users, refetch],
|
|
137
|
+
);
|
|
138
|
+
return {
|
|
139
|
+
...state,
|
|
140
|
+
remind,
|
|
141
|
+
users: data && Array.isArray(data.data) ? data.data : [],
|
|
142
|
+
total: data && data.meta && Number.isFinite(data.meta.total) ? data.meta.total : null,
|
|
143
|
+
};
|
|
144
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@colixsystems/widget-sdk",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.205.0",
|
|
4
4
|
"description": "Common widget interface for AppStudio. Implements WidgetManifest, WidgetContext, property schema, and helper hooks.",
|
|
5
5
|
"homepage": "https://github.com/Colix-AB/AppStudio",
|
|
6
6
|
"type": "module",
|
package/src/contract.js
CHANGED
|
@@ -692,6 +692,18 @@ const THEME_COMPONENTS = Object.freeze({
|
|
|
692
692
|
"appstudio.user-management": TABS_FIELDS,
|
|
693
693
|
}),
|
|
694
694
|
}),
|
|
695
|
+
// Loading placeholders: `<Skeleton>` reads this scope directly, so it names
|
|
696
|
+
// the SDK component it styles and binds no widget field. `<Spinner>` is not
|
|
697
|
+
// here: it wears `colors.loader`, the app's one spinner colour.
|
|
698
|
+
loading: Object.freeze({
|
|
699
|
+
label: "Loading",
|
|
700
|
+
sdkComponents: Object.freeze(["Skeleton"]),
|
|
701
|
+
tokens: Object.freeze({
|
|
702
|
+
skeletonColor: Object.freeze({ type: "color", uiDefault: "colors.surfaceMuted" }),
|
|
703
|
+
skeletonRadius: Object.freeze({ type: "size", min: 0, max: 24, uiDefault: "radii.sm" }),
|
|
704
|
+
}),
|
|
705
|
+
targets: Object.freeze({}),
|
|
706
|
+
}),
|
|
695
707
|
});
|
|
696
708
|
|
|
697
709
|
// sc-4505 — the value types a widget event payload field may declare. Narrower
|
|
@@ -717,7 +729,8 @@ const HOOKS = [
|
|
|
717
729
|
"page: a container painting its own background re-derives them for its " +
|
|
718
730
|
"subtree. Read them and your text is readable wherever the widget lands; " +
|
|
719
731
|
"there is nothing to opt into. `loader` is that surface's spinner colour " +
|
|
720
|
-
"(sc-6095) —
|
|
732
|
+
"(sc-6095) — `<Spinner>` wears it by default; paint your own loading " +
|
|
733
|
+
"state with it rather than a literal grey.",
|
|
721
734
|
spacing: "{ xs, sm, md, lg, xl }",
|
|
722
735
|
spacingScale:
|
|
723
736
|
"number — the app-wide spacing multiplier (REQ-THEME-LOOK, default 1). " +
|
|
@@ -1962,6 +1975,95 @@ const HOOKS = [
|
|
|
1962
1975
|
requiredContextSlice: ["directory.users"],
|
|
1963
1976
|
scopes: ["users.read:*"],
|
|
1964
1977
|
},
|
|
1978
|
+
// sc-10691 / REQ-USERMGMT-10 — an app's user totals for its own admins.
|
|
1979
|
+
{
|
|
1980
|
+
name: "useUserStats",
|
|
1981
|
+
signature: "useUserStats({ days, group_id })",
|
|
1982
|
+
optionalArgs: ["days", "group_id"],
|
|
1983
|
+
description:
|
|
1984
|
+
"Workspace user TOTALS for an app's own administrators — never a list " +
|
|
1985
|
+
"of people. `days` is 7 or 30 (charted per day) or 90 (per ISO week), in " +
|
|
1986
|
+
"the workspace's time zone (`stats.period.time_zone`); " +
|
|
1987
|
+
"`group_id` (a useGroups() row id) narrows the user and invite " +
|
|
1988
|
+
"figures to that group. `stats.users` = { active, deactivated, " +
|
|
1989
|
+
"without_group }; `stats.new_users` = { total, previous_total, buckets: " +
|
|
1990
|
+
"[{ date, count }] } with every bucket present and previous_total the " +
|
|
1991
|
+
"equal-length window before the period; `stats.groups` and " +
|
|
1992
|
+
"`stats.empty_groups`; `stats.invites` = { " +
|
|
1993
|
+
"pending, expiring_soon, expired, accepted }. `without_group`, `groups` " +
|
|
1994
|
+
"and `empty_groups` are null on a group-scoped read. `invites` is null unless " +
|
|
1995
|
+
"the widget also declares users.write:* AND the viewer holds users.write " +
|
|
1996
|
+
"— render \"no access\" for that section rather than zeros. A viewer " +
|
|
1997
|
+
"without the admin user view gets error.code FORBIDDEN; show an " +
|
|
1998
|
+
"explicit no-access state.",
|
|
1999
|
+
returnShape: {
|
|
2000
|
+
stats:
|
|
2001
|
+
"{ period, group_id, users, new_users, groups, empty_groups, invites } | null",
|
|
2002
|
+
loading: "boolean",
|
|
2003
|
+
fetching: "boolean",
|
|
2004
|
+
error: "DirectoryError | null // FORBIDDEN without the admin user view, NOT_FOUND for a foreign group",
|
|
2005
|
+
refetch: "() => Promise<void>",
|
|
2006
|
+
},
|
|
2007
|
+
requiredContextSlice: ["directory.users"],
|
|
2008
|
+
scopes: ["users.read:*"],
|
|
2009
|
+
},
|
|
2010
|
+
// sc-10691 / REQ-USERMGMT-12 — user activity, behind users.activity.
|
|
2011
|
+
{
|
|
2012
|
+
name: "useUserActivity",
|
|
2013
|
+
signature: "useUserActivity({ days, group_id })",
|
|
2014
|
+
optionalArgs: ["days", "group_id"],
|
|
2015
|
+
description:
|
|
2016
|
+
"User ACTIVITY for an app's administrators, for a workspace that " +
|
|
2017
|
+
"records it: `activity.online_now` (app open in the last two minutes), " +
|
|
2018
|
+
"`activity.active` = { day (since midnight), week, month (last 7/30 " +
|
|
2019
|
+
"days), period (distinct users active in the period) }, " +
|
|
2020
|
+
"`activity.never_active`, " +
|
|
2021
|
+
"`activity.daily_active` = [{ date, count }] distinct active users per " +
|
|
2022
|
+
"day (7/30 days) or week (90) in the workspace's time zone, `activity.platforms` = { web, ios, " +
|
|
2023
|
+
"android, unknown } users active in the period, `activity.by_group` = " +
|
|
2024
|
+
"[{ group_id, name, users, active }] (null on a group-scoped read), " +
|
|
2025
|
+
"`activity.users` and `activity.recording_since` (activity before it " +
|
|
2026
|
+
"is unknown — say \"since <date>\" next to never-active figures). Needs " +
|
|
2027
|
+
"users.activity:* AND the viewer's \"View user activity\" permission " +
|
|
2028
|
+
"(else error.code FORBIDDEN). error.reason \"ACTIVITY_UNAVAILABLE\" means " +
|
|
2029
|
+
"user activity is not offered to this workspace yet: hide the section, " +
|
|
2030
|
+
"like FORBIDDEN. When the workspace does not record activity, " +
|
|
2031
|
+
"error.reason is \"ACTIVITY_DISABLED\": show that state, not zeros. " +
|
|
2032
|
+
"Pass null instead of a query to read nothing (a section switched off).",
|
|
2033
|
+
returnShape: {
|
|
2034
|
+
activity:
|
|
2035
|
+
"{ period, group_id, recording_since, users, online_now, active, never_active, daily_active, platforms, by_group } | null",
|
|
2036
|
+
loading: "boolean",
|
|
2037
|
+
fetching: "boolean",
|
|
2038
|
+
error: "DirectoryError | null // FORBIDDEN; reason ACTIVITY_UNAVAILABLE (hide) or ACTIVITY_DISABLED (not recorded)",
|
|
2039
|
+
refetch: "() => Promise<void>",
|
|
2040
|
+
},
|
|
2041
|
+
requiredContextSlice: ["directory.users"],
|
|
2042
|
+
scopes: ["users.activity:*"],
|
|
2043
|
+
},
|
|
2044
|
+
{
|
|
2045
|
+
name: "useInactiveUsers",
|
|
2046
|
+
signature: "useInactiveUsers({ inactive_days, group_id, limit, offset } | null)",
|
|
2047
|
+
optionalArgs: ["inactive_days", "group_id", "limit", "offset"],
|
|
2048
|
+
description:
|
|
2049
|
+
"One page of users not seen for `inactive_days` (1-365, default 30), " +
|
|
2050
|
+
"never-seen first, then least recently seen: `users` = [{ id, name, " +
|
|
2051
|
+
"last_active_at, last_reminded_at }] (last_active_at null = never seen " +
|
|
2052
|
+
"since recording began), `total` for paging. `remind(id, { title, body })` " +
|
|
2053
|
+
"notifies one of them, records when (last_reminded_at) and re-reads. " +
|
|
2054
|
+
"Same gate and errors as useUserActivity; null reads nothing.",
|
|
2055
|
+
returnShape: {
|
|
2056
|
+
users: "Array<{ id, name, last_active_at, last_reminded_at }>",
|
|
2057
|
+
total: "number | null",
|
|
2058
|
+
loading: "boolean",
|
|
2059
|
+
fetching: "boolean",
|
|
2060
|
+
error: "DirectoryError | null",
|
|
2061
|
+
refetch: "() => Promise<void>",
|
|
2062
|
+
remind: "(id, { title, body }) => Promise<void>",
|
|
2063
|
+
},
|
|
2064
|
+
requiredContextSlice: ["directory.users"],
|
|
2065
|
+
scopes: ["users.activity:*"],
|
|
2066
|
+
},
|
|
1965
2067
|
// REQ-USERMGMT / REQ-ACL-SYS M3 — AppUserGroup administration. Returns
|
|
1966
2068
|
// `{ groups, loading, error, refetch, create, remove, addMember, removeMember }`.
|
|
1967
2069
|
// Reads need `groups.read:*`; mutations need `groups.write:*`. Removing a
|
|
@@ -2608,7 +2710,7 @@ const PRIMITIVES = [
|
|
|
2608
2710
|
{
|
|
2609
2711
|
name: "ActivityIndicator",
|
|
2610
2712
|
description:
|
|
2611
|
-
"
|
|
2713
|
+
"The raw platform spinner. Do NOT use it for a loading state — draw `<Spinner>` (below), which wears the app's spinner colour and follows the reduced-motion setting. See https://reactnative.dev/docs/activityindicator.",
|
|
2612
2714
|
rnComponent: "ActivityIndicator",
|
|
2613
2715
|
docsUrl: "https://reactnative.dev/docs/activityindicator",
|
|
2614
2716
|
},
|
|
@@ -2846,6 +2948,20 @@ const PRIMITIVES = [
|
|
|
2846
2948
|
rnComponent: null,
|
|
2847
2949
|
docsUrl: null,
|
|
2848
2950
|
},
|
|
2951
|
+
{
|
|
2952
|
+
name: "Spinner",
|
|
2953
|
+
description:
|
|
2954
|
+
'Themed loading spinner. `<Spinner />`, `<Spinner size="lg" label="Loading orders" />` — THE way to show that something is in progress when you cannot know its shape: a button\'s pending action, a save, a refresh, "load more", a full-widget first load with no layout yet. `size` is "sm" | "md" (default) | "lg" or a number; `label` draws a caption under the ring and is its accessibility label (unset, the app\'s own "Loading…"). Its colour is the app\'s one spinner colour (`theme.colors.loader`, set under Design → Loading Indicator and re-derived for whatever surface the widget sits on), so pass `color` ONLY inside a control you fill yourself, e.g. `color={theme.colors.onPrimary}` inside a primary button. It stops turning when the device asks for reduced motion and is a labelled element in the busy state. Never use `ActivityIndicator` or a hand-drawn spinner.',
|
|
2955
|
+
rnComponent: null,
|
|
2956
|
+
docsUrl: null,
|
|
2957
|
+
},
|
|
2958
|
+
{
|
|
2959
|
+
name: "Skeleton",
|
|
2960
|
+
description:
|
|
2961
|
+
'Themed loading placeholder. `<Skeleton shape="text" width="60%" />`, `<Skeleton shape="text" lines={3} />`, `<Skeleton shape="block" height={160} />`, `<Skeleton shape="circle" size={40} />`, and the list preset `<Skeleton shape="listRow" count={4} />` (pass `leading={false}` for rows with no avatar or icon). THE loading state for content whose layout you already know — a list, a card, a profile, an article: draw the shape of what is coming so nothing jumps when it arrives. To mirror a custom layout, wrap bones in one group and lay them out yourself: `<Skeleton style={{ flexDirection: "row", gap: 12 }}><Skeleton shape="circle" /><Skeleton shape="text" lines={2} style={{ flex: 1 }} /></Skeleton>` — the outermost Skeleton is the ONE loading region (a `status` region with `aria-busy` on the web, one labelled accessible element in the busy state on native) and every bone inside is hidden from assistive technology. Its colour and corner radius come from the theme (Design → Components → Loading); `color` and `radius` override one skeleton. The outermost Skeleton pulses as a whole, and stops under reduced motion. Never hand-build placeholder bars from `View`.',
|
|
2962
|
+
rnComponent: null,
|
|
2963
|
+
docsUrl: null,
|
|
2964
|
+
},
|
|
2849
2965
|
];
|
|
2850
2966
|
|
|
2851
2967
|
const CATEGORIES = [
|
|
@@ -5335,7 +5451,16 @@ const CONTRACT = deepFreeze({
|
|
|
5335
5451
|
// 1.161.0: additive (sc-10930) — `Accordion` / `AccordionItem`, themed
|
|
5336
5452
|
// collapsible sections drawn from the `card` and `list` scopes: single
|
|
5337
5453
|
// or multiple open, controlled or uncontrolled, `aria-expanded` headers.
|
|
5338
|
-
|
|
5454
|
+
// 1.163.0: additive (sc-10928) — the `Spinner` and `Skeleton` primitives.
|
|
5455
|
+
// `Spinner` wears `colors.loader`; `Skeleton` reads the new `loading`
|
|
5456
|
+
// component scope (colour, radius). A scope may now name `sdkComponents`
|
|
5457
|
+
// and bind no widget field. Both stop under reduced motion.
|
|
5458
|
+
// 1.164.0: additive (sc-10691) — `useUserStats(query)` reads an app's user
|
|
5459
|
+
// totals through the new `ctx.directory.users.stats`; `useUserActivity`
|
|
5460
|
+
// and `useInactiveUsers` read user activity through the new
|
|
5461
|
+
// `ctx.directory.users.activity` / `.inactive` / `.remind`, behind the
|
|
5462
|
+
// new `users.activity:*` scope.
|
|
5463
|
+
version: "1.164.0",
|
|
5339
5464
|
sharedTranslationKeys: SHARED_TRANSLATION_KEYS,
|
|
5340
5465
|
hooks: HOOKS,
|
|
5341
5466
|
primitives: PRIMITIVES,
|