@gogitcms/editor 0.26.0 → 0.28.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/app/src/App.tsx +163 -1
- package/app/src/forms.ts +77 -0
- package/app/src/history.ts +50 -0
- package/app/src/queries.ts +163 -0
- package/app/vendor/design-system/src/__tests__/ContentBrowser.forms.test.tsx +157 -0
- package/app/vendor/design-system/src/__tests__/ContentBrowser.history.test.tsx +385 -0
- package/app/vendor/design-system/src/__tests__/ContentBrowser.historycollab.test.tsx +433 -0
- package/app/vendor/design-system/src/__tests__/FormsBrowser.test.tsx +253 -0
- package/app/vendor/design-system/src/__tests__/ProtectedBranchModal.test.tsx +68 -0
- package/app/vendor/design-system/src/components/ChangeDetail.tsx +94 -11
- package/app/vendor/design-system/src/components/CollabField.tsx +71 -0
- package/app/vendor/design-system/src/components/ContentBrowser.tsx +366 -61
- package/app/vendor/design-system/src/components/DocumentHistory.tsx +408 -0
- package/app/vendor/design-system/src/components/FormsBrowser.tsx +543 -0
- package/app/vendor/design-system/src/components/ProtectedBranchModal.tsx +136 -0
- package/app/vendor/design-system/src/components/primitives.tsx +46 -1
- package/app/vendor/design-system/src/forms.ts +118 -0
- package/app/vendor/design-system/src/history.ts +82 -0
- package/app/vendor/design-system/src/index.ts +18 -1
- package/app/vendor/markdown-editor/src/MarkdownEditor.tsx +23 -0
- package/app/vendor/markdown-editor/src/field.tsx +6 -1
- package/app/vendor/markdown-editor/src/types.ts +13 -0
- package/npm-shrinkwrap.json +149 -188
- 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;
|