@gogitcms/editor 0.26.0 → 0.29.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 (24) hide show
  1. package/app/src/App.tsx +163 -1
  2. package/app/src/forms.ts +77 -0
  3. package/app/src/history.ts +50 -0
  4. package/app/src/queries.ts +163 -0
  5. package/app/vendor/design-system/src/__tests__/ContentBrowser.forms.test.tsx +157 -0
  6. package/app/vendor/design-system/src/__tests__/ContentBrowser.history.test.tsx +385 -0
  7. package/app/vendor/design-system/src/__tests__/ContentBrowser.historycollab.test.tsx +433 -0
  8. package/app/vendor/design-system/src/__tests__/FormsBrowser.test.tsx +253 -0
  9. package/app/vendor/design-system/src/__tests__/ProtectedBranchModal.test.tsx +68 -0
  10. package/app/vendor/design-system/src/components/ChangeDetail.tsx +94 -11
  11. package/app/vendor/design-system/src/components/CollabField.tsx +71 -0
  12. package/app/vendor/design-system/src/components/ContentBrowser.tsx +366 -61
  13. package/app/vendor/design-system/src/components/DocumentHistory.tsx +408 -0
  14. package/app/vendor/design-system/src/components/FormsBrowser.tsx +543 -0
  15. package/app/vendor/design-system/src/components/ProtectedBranchModal.tsx +136 -0
  16. package/app/vendor/design-system/src/components/primitives.tsx +46 -1
  17. package/app/vendor/design-system/src/forms.ts +118 -0
  18. package/app/vendor/design-system/src/history.ts +82 -0
  19. package/app/vendor/design-system/src/index.ts +18 -1
  20. package/app/vendor/markdown-editor/src/MarkdownEditor.tsx +23 -0
  21. package/app/vendor/markdown-editor/src/field.tsx +6 -1
  22. package/app/vendor/markdown-editor/src/types.ts +13 -0
  23. package/npm-shrinkwrap.json +155 -168
  24. package/package.json +6 -6
@@ -1,7 +1,8 @@
1
1
  import React from "react";
2
- import { View, StyleProp, ViewStyle } from "react-native";
2
+ import { View, Pressable, StyleProp, ViewStyle } from "react-native";
3
3
  import { useTheme } from "../ThemeProvider";
4
4
  import { Text } from "./Text";
5
+ import { Icon } from "./Icon";
5
6
 
6
7
  /** Bordered surface card — defined by a hairline border, never a shadow. */
7
8
  export function Card({
@@ -141,3 +142,47 @@ export function SectionLabel({ children, style }: { children: React.ReactNode; s
141
142
  </View>
142
143
  );
143
144
  }
145
+
146
+ /**
147
+ * A square check box: filled and ticked when on, an empty hairline box when
148
+ * off.
149
+ *
150
+ * Lives here rather than beside its first caller because two surfaces select
151
+ * things now — entry rows in the browser, and the fields of a past version in
152
+ * the history diff — and they have to look like the same control. The press
153
+ * stops propagating: every current caller puts this inside a larger Pressable
154
+ * (a list row, a field block), and a tick that also opened the row would be
155
+ * unusable.
156
+ */
157
+ export function CheckBox({
158
+ value,
159
+ onToggle,
160
+ testID = "check-box",
161
+ }: {
162
+ value: boolean;
163
+ onToggle: () => void;
164
+ testID?: string;
165
+ }) {
166
+ const t = useTheme();
167
+ return (
168
+ <Pressable
169
+ onPress={(e?: { stopPropagation?: () => void }) => {
170
+ e?.stopPropagation?.();
171
+ onToggle();
172
+ }}
173
+ testID={testID}
174
+ style={{
175
+ width: 18,
176
+ height: 18,
177
+ borderRadius: t.radius.sm,
178
+ borderWidth: 1,
179
+ borderColor: value ? t.color.borderStrong : t.color.borderDefault,
180
+ backgroundColor: value ? t.color.surfaceInverted : t.color.surfaceRaised,
181
+ alignItems: "center",
182
+ justifyContent: "center",
183
+ }}
184
+ >
185
+ {value ? <Icon name="check" size={12} color={t.color.textInverted} /> : null}
186
+ </Pressable>
187
+ );
188
+ }
@@ -0,0 +1,118 @@
1
+ // Forms in the editor: the nav-key space they occupy and the data seam the host
2
+ // fills (docs/forms.md §10).
3
+ //
4
+ // The scheme mirrors media's exactly, and for the same reason: a collection name
5
+ // is restricted to [A-Za-z0-9_-] by the config schema, so it can never contain a
6
+ // colon — not even a collection literally named "forms".
7
+ //
8
+ // "forms:" the Forms button — the list of forms
9
+ // "forms:contact" one form's submissions, which is what makes it deep-linkable
10
+ const FORMS_NAV_PREFIX = "forms:";
11
+
12
+ /** FORMS_NAV_KEY is the Forms button's own key — forms with none selected. */
13
+ export const FORMS_NAV_KEY = FORMS_NAV_PREFIX;
14
+
15
+ /** formsNavKey returns the nav key for a form, or for the Forms button. */
16
+ export function formsNavKey(form?: string): string {
17
+ return form ? FORMS_NAV_PREFIX + form : FORMS_NAV_PREFIX;
18
+ }
19
+
20
+ /**
21
+ * isFormsNavKey reports whether a nav key addresses forms at all — true for the
22
+ * Forms button and for any form beneath it. This is what a host checks to know
23
+ * the forms surface is showing.
24
+ */
25
+ export function isFormsNavKey(key: string | null | undefined): boolean {
26
+ return !!key && key.startsWith(FORMS_NAV_PREFIX);
27
+ }
28
+
29
+ /**
30
+ * parseFormsNavKey returns the form a nav key selects: null for an ordinary
31
+ * collection, and also null for the bare Forms button (forms are showing, but no
32
+ * form is chosen). Pair it with isFormsNavKey to tell those two apart.
33
+ */
34
+ export function parseFormsNavKey(key: string | null | undefined): string | null {
35
+ if (!isFormsNavKey(key)) return null;
36
+ const form = key!.slice(FORMS_NAV_PREFIX.length);
37
+ return form.length > 0 ? form : null;
38
+ }
39
+
40
+ /** One field of a form, as the editor needs to render a submission. */
41
+ export type FormFieldInfo = {
42
+ name: string;
43
+ label?: string | null;
44
+ /** string | integer | float | boolean | array | object */
45
+ type: string;
46
+ component?: string | null;
47
+ };
48
+
49
+ /** One form, with the summary numbers the Forms list shows. */
50
+ export type FormInfo = {
51
+ name: string;
52
+ label?: string | null;
53
+ description?: string | null;
54
+ fields: FormFieldInfo[];
55
+ fieldCount: number;
56
+ submissionCount: number;
57
+ lastSubmissionAt?: string | null;
58
+ /** True when the form writes its submissions to git. */
59
+ versioned: boolean;
60
+ storePath?: string | null;
61
+ canDelete: boolean;
62
+ };
63
+
64
+ /** One submission, read-only by construction — a record of what someone sent. */
65
+ export type SubmissionInfo = {
66
+ id: string;
67
+ form: string;
68
+ /** Field name → value, missing entirely for a field whose condition was false. */
69
+ fields: Record<string, unknown>;
70
+ submittedAt: string;
71
+ /** "received" | "spam" */
72
+ status: string;
73
+ /** Where it lives in the repository, when the form versions its submissions. */
74
+ path?: string | null;
75
+ };
76
+
77
+ /**
78
+ * FormsApi is the data seam between the design system and its host: the DS owns
79
+ * the browsing UI, the host owns fetching (Apollo on web/mobile, REST on
80
+ * desktop). Omitting it from ContentBrowser hides the Forms surface entirely,
81
+ * which is what a config declaring no forms should look like.
82
+ */
83
+ export interface FormsApi {
84
+ /** The forms on this branch, with their summary numbers. */
85
+ forms: FormInfo[];
86
+ /** One page of a form's submissions, newest first. */
87
+ list: (params: {
88
+ form: string;
89
+ status?: string;
90
+ search?: string;
91
+ limit?: number;
92
+ offset?: number;
93
+ }) => Promise<SubmissionInfo[]>;
94
+ /** How many match, ignoring paging — the total behind "12 of 340". */
95
+ count: (params: { form: string; status?: string; search?: string }) => Promise<number>;
96
+ /** Delete submissions. Omit to hide the affordance. */
97
+ remove?: (ids: string[]) => Promise<void>;
98
+ }
99
+
100
+ /**
101
+ * summaryLine builds the one-line label a submission row shows.
102
+ *
103
+ * A submission has no title — what makes one recognisable is what the person
104
+ * wrote — so the first field carrying readable text is used, preferring an
105
+ * email-ish value because that is what a reader scans an inbox for.
106
+ */
107
+ export function summaryLine(sub: SubmissionInfo, form?: FormInfo): string {
108
+ const order = form ? form.fields.map((f) => f.name) : Object.keys(sub.fields);
109
+ let firstText = "";
110
+ for (const name of order) {
111
+ const v = sub.fields[name];
112
+ if (typeof v !== "string" || v.trim() === "") continue;
113
+ if (v.includes("@")) return v.trim();
114
+ if (!firstText) firstText = v.trim();
115
+ }
116
+ if (firstText) return firstText.length > 80 ? firstText.slice(0, 79) + "…" : firstText;
117
+ return "(no text)";
118
+ }
@@ -0,0 +1,82 @@
1
+ import type { DocumentChange } from "./components/ChangeDetail";
2
+
3
+ /**
4
+ * One commit in a document's history — a row in the version list.
5
+ *
6
+ * This is the provider's history for the document's CURRENT path. GitHub's
7
+ * commits API has no equivalent of `git log --follow`, so a renamed document's
8
+ * history stops at the rename; the list says so at its end rather than letting
9
+ * the absence read as "this is where the document began".
10
+ */
11
+ export type DocumentVersion = {
12
+ sha: string;
13
+ /** First 7 characters — an identity without the noise. */
14
+ shortSha: string;
15
+ /** The full commit message; the list shows its first line. */
16
+ message: string;
17
+ authorName: string;
18
+ authorEmail?: string | null;
19
+ /** ISO 8601, in UTC. */
20
+ authoredAt: string;
21
+ /** The commit's page on the provider. */
22
+ url: string;
23
+ /** How many files the commit touched — this document is one of them. */
24
+ filesChanged: number;
25
+ };
26
+
27
+ /**
28
+ * HistoryApi is the data seam between the design system and its host, the same
29
+ * split MediaApi and FormsApi use: the DS owns the version browser's UI and its
30
+ * (ephemeral) open/selected state, the host owns fetching. Omitting it from
31
+ * ContentBrowser hides the history affordance entirely, which is what a
32
+ * deployment with no provider — local mode, the desktop app — should look like.
33
+ */
34
+ export interface HistoryApi {
35
+ /**
36
+ * One page of a document's versions, newest first. The DS pages as the list
37
+ * scrolls; a short page (fewer than `limit`) ends it.
38
+ */
39
+ list: (params: { documentId: string; limit: number; offset: number }) => Promise<DocumentVersion[]>;
40
+ /**
41
+ * The document as it was at one commit, diffed against what it is now:
42
+ * `before` on each field is the value at that version, `after` its current
43
+ * value. Unchanged fields are included, which is what makes the result a
44
+ * readable preview of the whole document rather than a list of differences.
45
+ *
46
+ * Null when the version can't be read (a commit that has gone away under a
47
+ * force push, say) — the pane says so instead of showing an empty document.
48
+ */
49
+ get: (params: { documentId: string; sha: string }) => Promise<DocumentChange | null>;
50
+ }
51
+
52
+ /**
53
+ * firstLine is the summary a version row shows. A commit message's first line is
54
+ * its subject by convention, and the body below it is prose that would push
55
+ * every other row off the screen.
56
+ */
57
+ export function firstLine(message: string): string {
58
+ const line = message.split("\n", 1)[0].trim();
59
+ return line || "(no commit message)";
60
+ }
61
+
62
+ /**
63
+ * relativeTime renders a commit's age the way a history reads — "3d ago" —
64
+ * falling back to the date once "ago" stops being informative.
65
+ *
66
+ * `now` is injectable so a test can assert a fixed string rather than race the
67
+ * clock.
68
+ */
69
+ export function relativeTime(iso: string, now: Date = new Date()): string {
70
+ const then = new Date(iso);
71
+ const seconds = Math.round((now.getTime() - then.getTime()) / 1000);
72
+ if (!Number.isFinite(seconds)) return "";
73
+ if (seconds < 60) return "just now";
74
+ const minutes = Math.round(seconds / 60);
75
+ if (minutes < 60) return `${minutes}m ago`;
76
+ const hours = Math.round(minutes / 60);
77
+ if (hours < 24) return `${hours}h ago`;
78
+ const days = Math.round(hours / 24);
79
+ if (days < 30) return `${days}d ago`;
80
+ // Past a month, "5 weeks ago" is harder to place than the date itself.
81
+ return then.toLocaleDateString(undefined, { year: "numeric", month: "short", day: "numeric" });
82
+ }
@@ -55,6 +55,8 @@ export type {
55
55
  } from "./components/ApplyChangesModal";
56
56
 
57
57
  // Change-request summary surface (developer escalation)
58
+ export { ProtectedBranchModal } from "./components/ProtectedBranchModal";
59
+ export type { ProtectedBranchModalProps } from "./components/ProtectedBranchModal";
58
60
  export { ChangeRequestSummary } from "./components/ChangeRequestSummary";
59
61
  export type {
60
62
  ChangeRequestSummaryProps,
@@ -63,7 +65,11 @@ export type {
63
65
  } from "./components/ChangeRequestSummary";
64
66
 
65
67
  // Changes surface: one document's diff between a branch and its base
66
- export { ChangeDetail } from "./components/ChangeDetail";
68
+ export { ChangeDetail, BODY_FIELD } from "./components/ChangeDetail";
69
+ export { DocumentHistory } from "./components/DocumentHistory";
70
+ export type { DocumentHistoryProps } from "./components/DocumentHistory";
71
+ export { firstLine, relativeTime } from "./history";
72
+ export type { DocumentVersion, HistoryApi } from "./history";
67
73
  export type {
68
74
  ChangeDetailProps,
69
75
  DocumentChange,
@@ -196,3 +202,14 @@ export type {
196
202
  CollabPeer,
197
203
  CollabParticipant,
198
204
  } from "./components/ContentBrowser";
205
+
206
+ export { FormsBrowser } from "./components/FormsBrowser";
207
+ export type { FormsBrowserProps } from "./components/FormsBrowser";
208
+ export {
209
+ FORMS_NAV_KEY,
210
+ formsNavKey,
211
+ isFormsNavKey,
212
+ parseFormsNavKey,
213
+ summaryLine,
214
+ } from "./forms";
215
+ export type { FormsApi, FormInfo, FormFieldInfo, SubmissionInfo } from "./forms";
@@ -32,6 +32,7 @@ export default function MarkdownEditor({
32
32
  expandable = true,
33
33
  toolbar = true,
34
34
  collab,
35
+ resetNonce,
35
36
  }: MarkdownEditorProps) {
36
37
  const flavor = useMemo(() => resolveFlavor(flavorId), [flavorId]);
37
38
  const [expanded, setExpanded] = useState(false);
@@ -189,6 +190,28 @@ export default function MarkdownEditor({
189
190
  if (timer.current) clearTimeout(timer.current);
190
191
  }, []);
191
192
 
193
+ // A host-driven replacement of the whole document (see props.resetNonce).
194
+ //
195
+ // Done as a transaction rather than by rebuilding the state, because in a
196
+ // live session rebuilding would drop the collaborative plugins' view of the
197
+ // document: the replacement has to travel through ySyncPlugin to reach the
198
+ // shared fragment and the other clients, and through the undo plugin to stay
199
+ // undoable. Out of a session it is the same transaction against a plain doc.
200
+ const lastReset = useRef(resetNonce);
201
+ useEffect(() => {
202
+ if (resetNonce === undefined || resetNonce === lastReset.current) return;
203
+ lastReset.current = resetNonce;
204
+ setState((prev) => {
205
+ const next = flavor.parse(valueRef.current);
206
+ // Compared before replacing: an identical body would otherwise be deleted
207
+ // and reinserted, which in a CRDT is a real edit that peers see (and that
208
+ // steps on anyone whose cursor was inside it).
209
+ if (prev.doc.eq(next)) return prev;
210
+ return prev.apply(prev.tr.replaceWith(0, prev.doc.content.size, next.content));
211
+ });
212
+ // eslint-disable-next-line react-hooks/exhaustive-deps
213
+ }, [resetNonce]);
214
+
192
215
  const dispatchTransaction = useCallback(
193
216
  (tr: Transaction) => {
194
217
  setState((prev) => {
@@ -22,6 +22,10 @@ export type MarkdownFieldArgs = {
22
22
  subscribeSynced?(cb: () => void): () => void;
23
23
  };
24
24
  path?: string;
25
+ // Bumped by the host to replace the editor's content with `value` — the one
26
+ // operation `value` alone cannot express in a live session, where the shared
27
+ // fragment is the source of truth. See MarkdownEditorProps.resetNonce.
28
+ resetNonce?: number;
25
29
  };
26
30
 
27
31
  export type MarkdownFieldOptions = {
@@ -35,7 +39,7 @@ export type MarkdownFieldOptions = {
35
39
  // the web and native apps.
36
40
  export function createMarkdownRenderField(options: MarkdownFieldOptions = {}) {
37
41
  const { theme = "light", defaultFlavor = "gfm" } = options;
38
- return ({ field, value, onChange, readOnly, collab, path }: MarkdownFieldArgs): React.ReactNode => {
42
+ return ({ field, value, onChange, readOnly, collab, path, resetNonce }: MarkdownFieldArgs): React.ReactNode => {
39
43
  if (field.component !== "body") return undefined;
40
44
  if (field.format && field.format !== "markdown") return undefined;
41
45
  const flavor = (field.flavor as MarkdownFlavor) || defaultFlavor;
@@ -61,6 +65,7 @@ export function createMarkdownRenderField(options: MarkdownFieldOptions = {}) {
61
65
  readOnly={readOnly}
62
66
  dom={{ matchContents: true }}
63
67
  collab={collabProp}
68
+ resetNonce={resetNonce}
64
69
  />
65
70
  );
66
71
  };
@@ -30,6 +30,19 @@ export interface MarkdownEditorProps {
30
30
  expandable?: boolean;
31
31
  // Show the formatting toolbar at the top of the editor. Default true.
32
32
  toolbar?: boolean;
33
+ // Bumping this replaces the editor's content with `value`, discarding what is
34
+ // in the document — the one operation `value` alone cannot express.
35
+ //
36
+ // A controlled editor normally adopts `value` when it differs, but not in a
37
+ // live session: there the shared fragment is the source of truth and `value`
38
+ // is ignored entirely, so a host that wants to REPLACE the body (restoring it
39
+ // from a past version) has no way to say so. The nonce is that signal, and it
40
+ // is a nonce rather than a boolean because the same restore can be applied
41
+ // twice and must take effect both times.
42
+ //
43
+ // In a live session the replacement goes through a normal transaction, so it
44
+ // reaches the shared fragment and every peer, and stays undoable.
45
+ resetNonce?: number;
33
46
  // Expo DOM-component WebView props on native; ignored on web. Typed loosely so
34
47
  // the package need not depend on expo/react-native types.
35
48
  dom?: unknown;