@gogitcms/editor 0.25.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.
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 +149 -188
  24. package/package.json +6 -6
package/app/src/App.tsx CHANGED
@@ -51,9 +51,12 @@ import { beginAuthorize, beginLogout, beginGitHub, brokeredToken, handleCallback
51
51
  import { useNavigation, useRoute, StackActions } from "@react-navigation/native";
52
52
  import { NavigationRoot, RootStack, EMPTY_SELECTION, browserUrl, type BrowserParams, type CmsSelection } from "./navigation";
53
53
  import { isMediaNavKey, MEDIA_NAV_KEY } from "@gogitcms/design-system";
54
- import { LOGIN, WORKSPACES, REPOSITORIES, COLLECTIONS, DOCUMENTS, DOCUMENT_COUNT, DOCUMENT, ME, NOTIFICATIONS, MARK_NOTIFICATIONS_READ, UPDATE_DOCUMENT, CREATE_DOCUMENT, DELETE_DOCUMENTS, RENAME_DOCUMENT, MOVE_DOCUMENTS, BRANCH_CHANGE_SUMMARY, BRANCH_CHANGES, BRANCH_CHANGE, BRANCH_MEDIA_CHANGES, CREATE_BRANCH, APPLY_CHANGES, MERGE_RUN_PROGRESS, RESOLVE_MERGE_CONFLICT, OPEN_CHANGE_REQUEST, CHANGE_REQUEST, CHANGE_REQUEST_FOR_BRANCH, CREATE_CHANGE_REQUEST, BRANCH_IMPORT, BRANCH_IMPORT_PROGRESS } from "./queries";
54
+ import { FORMS_NAV_KEY, isFormsNavKey, parseFormsNavKey } from "@gogitcms/design-system";
55
+ import { LOGIN, WORKSPACES, REPOSITORIES, COLLECTIONS, DOCUMENTS, DOCUMENT_COUNT, DOCUMENT, ME, NOTIFICATIONS, MARK_NOTIFICATIONS_READ, UPDATE_DOCUMENT, CREATE_DOCUMENT, DELETE_DOCUMENTS, RENAME_DOCUMENT, MOVE_DOCUMENTS, BRANCH_CHANGE_SUMMARY, BRANCH_CHANGES, BRANCH_CHANGE, BRANCH_MEDIA_CHANGES, CREATE_BRANCH, APPLY_CHANGES, MERGE_RUN_PROGRESS, RESOLVE_MERGE_CONFLICT, OPEN_CHANGE_REQUEST, CHANGE_REQUEST, CHANGE_REQUEST_FOR_BRANCH, CREATE_CHANGE_REQUEST, BRANCH_IMPORT, BRANCH_IMPORT_PROGRESS, SAVE_DOCUMENT_ON_NEW_BRANCH } from "./queries";
55
56
  import type { EntryField, FacetField, DocFilter, SegmentItem, BranchRef, ProjectRef, DocumentChange, FieldChange, FieldChangeKind, MergeProgress, FieldConflict as FieldConflictT } from "@gogitcms/design-system";
56
57
  import { useMediaApi } from "./media";
58
+ import { useFormsApi } from "./forms";
59
+ import { useHistoryApi } from "./history";
57
60
 
58
61
  // How many documents to fetch per page of the (server-paginated) entry list.
59
62
  const PAGE_SIZE = 50;
@@ -226,6 +229,26 @@ type Collection = {
226
229
  filename?: string | null; canCreate: boolean; canUpdate: boolean; canDelete: boolean; fields: FieldDef[];
227
230
  };
228
231
  type DocumentT = { id: string; path: string; label: string; fields: Record<string, unknown> | null; body?: string | null };
232
+
233
+ // A save a protected branch refused, held open while the author names a branch
234
+ // to put it on. `resolve`/`reject` are the editor's own save promise: finishing
235
+ // it settles the save the editor is still showing as in flight.
236
+ type ProtectedSave = {
237
+ change: { id: string; fields: Record<string, unknown>; body: string | null };
238
+ documentLabel?: string;
239
+ suggestedName: string;
240
+ resolve: () => void;
241
+ reject: (e: Error) => void;
242
+ };
243
+
244
+ // A branch name to offer for a refused save, derived from the document's file
245
+ // name — "content/blog/hello-world.md" → "edit/hello-world". A suggestion only:
246
+ // the field is editable, and an empty one just means the author types their own.
247
+ const suggestBranchName = (path?: string): string => {
248
+ const base = (path ?? "").split("/").pop() ?? "";
249
+ const slug = base.replace(/\.[^.]+$/, "").replace(/[^a-zA-Z0-9._-]+/g, "-").replace(/^-+|-+$/g, "").toLowerCase();
250
+ return slug ? `edit/${slug}` : "";
251
+ };
229
252
  // An open desktop tab: a document id + the collection it belongs to.
230
253
  // An open desktop column. `plugin` set → the column renders a plugin route
231
254
  // instead of a document, and `id` is the synthetic plugin tab id rather than a
@@ -714,9 +737,21 @@ function CmsView({
714
737
  // The media data seam handed to the design system. Undefined when the server
715
738
  // has no object store configured, which renders media fields read-only.
716
739
  const media = useMediaApi(repo.id, branch.id, projectName);
740
+ // Undefined when the branch's config declares no forms, which is what keeps
741
+ // the editor byte-identical for every repository that has none.
742
+ const forms = useFormsApi(repo.id, branch.id, projectName);
743
+ // Document history reads the provider's commit log through the repository's
744
+ // GitHub App installation. Local mode serves a working tree with no
745
+ // installation behind it, so the seam is undefined there and the history
746
+ // button never renders — a feature the user never sees beats one that errors.
747
+ const history = useHistoryApi(repo.id, !config.local);
717
748
  const navigation = useNavigation<any>();
718
749
  const route = useRoute();
719
750
  const params = route.params as BrowserParams;
751
+ // Forms live in the collection segment (a prefixed nav key), and a submission
752
+ // in the item segment — so a submission is deep-linkable with no change to the
753
+ // URL grammar. See navigation.tsx.
754
+ const formsShowing = isFormsNavKey(params.collection);
720
755
  // Selection changes keep the current repo/branch fixed and only vary the CMS
721
756
  // portion of the route. Callers may pass a partial selection; the rest resets.
722
757
  // The surface is deliberately carried forward rather than reset — selecting a
@@ -1599,6 +1634,27 @@ function CmsView({
1599
1634
  // rather than one per typing pause, which is what keeps a git-backed history
1600
1635
  // readable.
1601
1636
  const [updateDocument] = useMutation(UPDATE_DOCUMENT);
1637
+ // A save the branch refused because it is protected, suspended on the prompt.
1638
+ //
1639
+ // The save's own promise is held here rather than rejected, so from the
1640
+ // editor's point of view the save is still in flight while the author names a
1641
+ // branch. That is what keeps the draft machinery honest: a rejection would put
1642
+ // the column into its error state, and the successful save that follows would
1643
+ // be a save of a document the column is no longer showing.
1644
+ const [protectedSave, setProtectedSave] = useState<ProtectedSave | null>(null);
1645
+ const [creatingProtectedBranch, setCreatingProtectedBranch] = useState(false);
1646
+ const [protectedBranchError, setProtectedBranchError] = useState<string | null>(null);
1647
+ // A held-open save has to be settled if the prompt goes away without an answer
1648
+ // — leaving the promise pending would leave the editor reporting "saving" for
1649
+ // a save that nothing is going to finish. A branch switch is the reachable
1650
+ // case: it is a navigation, so the modal disappears with the branch it was
1651
+ // about, and the pending edit no longer belongs to what is on screen.
1652
+ const pendingSaveRef = useRef<ProtectedSave | null>(null);
1653
+ pendingSaveRef.current = protectedSave;
1654
+ useEffect(() => () => {
1655
+ pendingSaveRef.current?.reject(new Error("Save cancelled."));
1656
+ }, [branch.id]);
1657
+
1602
1658
  const onSaveEntry = async (change: { id: string; fields: Record<string, unknown>; body: string | null }) => {
1603
1659
  // Merge against the tab's loaded full field map (not the active-collection
1604
1660
  // list, which is now display-only) so cross-collection saves preserve keys.
@@ -1609,11 +1665,85 @@ function CmsView({
1609
1665
  variables: { repositoryId: repo.id, id: change.id, fields: merged, body: change.body },
1610
1666
  });
1611
1667
  } catch (e: any) {
1668
+ // A protected branch has one remedy and the editor knows it: put the edit
1669
+ // on a branch that takes writes. Rather than reporting a failure the author
1670
+ // then has to plan around, hold the save open on the prompt and finish it
1671
+ // there (or fail it, if they'd rather keep editing here).
1672
+ if (e?.graphQLErrors?.[0]?.extensions?.code === "BRANCH_PROTECTED") {
1673
+ setProtectedBranchError(null);
1674
+ return new Promise<void>((resolve, reject) => {
1675
+ setProtectedSave({
1676
+ change: { ...change, fields: merged },
1677
+ documentLabel: doc?.label,
1678
+ suggestedName: suggestBranchName(doc?.path),
1679
+ resolve,
1680
+ reject,
1681
+ });
1682
+ });
1683
+ }
1612
1684
  // Rethrow the server's message so the editor's autosave surfaces it.
1613
1685
  throw new Error(e?.graphQLErrors?.[0]?.message || e?.message || "Couldn’t save your changes.");
1614
1686
  }
1615
1687
  };
1616
1688
 
1689
+ // Finish a refused save on a new branch: one mutation creates the branch, finds
1690
+ // the same document there and applies the edit.
1691
+ const [saveOnNewBranch] = useMutation(SAVE_DOCUMENT_ON_NEW_BRANCH);
1692
+ const onCreateBranchAndSave = async (name: string) => {
1693
+ if (!protectedSave) return;
1694
+ const pending = protectedSave;
1695
+ setCreatingProtectedBranch(true);
1696
+ setProtectedBranchError(null);
1697
+ let created: { branch: Branch; document: DocumentT } | undefined;
1698
+ try {
1699
+ const res = await saveOnNewBranch({
1700
+ variables: {
1701
+ repositoryId: repo.id,
1702
+ fromBranchId: branch.id,
1703
+ name,
1704
+ documentId: pending.change.id,
1705
+ fields: pending.change.fields,
1706
+ body: pending.change.body,
1707
+ },
1708
+ });
1709
+ created = res.data?.saveDocumentOnNewBranch;
1710
+ } catch (e: any) {
1711
+ setProtectedBranchError(e?.graphQLErrors?.[0]?.message || e?.message || "Couldn’t create the branch.");
1712
+ setCreatingProtectedBranch(false);
1713
+ return;
1714
+ }
1715
+ setCreatingProtectedBranch(false);
1716
+ setProtectedSave(null);
1717
+ // The branch list is cached on the repository; the switcher needs the new one.
1718
+ await apollo.refetchQueries({ include: [REPOSITORIES] });
1719
+ // Resolve BEFORE navigating. The editor clears the document's stored draft in
1720
+ // the microtask that follows this resolve, and the column it does that from
1721
+ // unmounts the moment we navigate — so the deferred navigation below is what
1722
+ // lets the draft be cleared rather than left behind under the old id.
1723
+ pending.resolve();
1724
+ if (!created) return;
1725
+ const col = collections.find((c) => c.name === (openTabs.find((t: TabRef) => t.id === pending.change.id)?.collection ?? activeNavKey)) ?? active;
1726
+ setTimeout(() => {
1727
+ navigation.navigate("Browser", {
1728
+ workspaceId: params.workspaceId, repositoryId: repo.id, branchId: created!.branch.id,
1729
+ defaultBranchId, projectName, soleProjectName,
1730
+ ...EMPTY_SELECTION,
1731
+ collection: col?.name ?? activeNavKey,
1732
+ itemPath: col ? toRelItemPath(col.path, created!.document.path) : "",
1733
+ });
1734
+ }, 0);
1735
+ };
1736
+
1737
+ // Dismissed: the save genuinely failed, and saying so is what leaves the column
1738
+ // in its error state with the draft intact — the author keeps their work and
1739
+ // can try again (or switch branches themselves).
1740
+ const onCancelProtectedSave = () => {
1741
+ protectedSave?.reject(new Error(`${branch.name} is protected — save this to a new branch instead.`));
1742
+ setProtectedSave(null);
1743
+ setProtectedBranchError(null);
1744
+ setCreatingProtectedBranch(false);
1745
+ };
1746
+
1617
1747
  // Create: split the form values into a body-source field (if any) + frontmatter
1618
1748
  // fields, persist, then route to the new document's edit URL.
1619
1749
  const [createDocument] = useMutation(CREATE_DOCUMENT);
@@ -1798,6 +1928,20 @@ function CmsView({
1798
1928
  icon: "settings" as const,
1799
1929
  })),
1800
1930
  },
1931
+ // Forms, when the branch declares any. One row, like Media: the content
1932
+ // area is what lists the individual forms, because a form is a folder of
1933
+ // submissions rather than a collection of documents.
1934
+ {
1935
+ title: "",
1936
+ items: forms
1937
+ ? [{
1938
+ key: FORMS_NAV_KEY,
1939
+ label: "Forms",
1940
+ icon: "listTree" as const,
1941
+ count: forms.forms.reduce((n, f) => n + f.submissionCount, 0),
1942
+ }]
1943
+ : [],
1944
+ },
1801
1945
  // Plugin-contributed links, grouped by their declared section title
1802
1946
  // (default "Plugins"), keyed so onSelectNav can route them.
1803
1947
  ...pluginRegistry.sidebar().reduce<CmsNavSection[]>((acc, link) => {
@@ -2196,6 +2340,14 @@ function CmsView({
2196
2340
  }
2197
2341
  renderField={renderField}
2198
2342
  media={media}
2343
+ forms={forms}
2344
+ history={history}
2345
+ // The submission id rides in the item segment, exactly as a document path
2346
+ // does, so selection survives a reload and is shareable.
2347
+ selectedSubmissionId={formsShowing ? params.itemPath || null : null}
2348
+ onSelectSubmission={(id) =>
2349
+ go({ configure: false, collection: params.collection, itemPath: id ?? "" })
2350
+ }
2199
2351
  // Nothing on the changes surface is editable; nor is a frozen branch (one
2200
2352
  // handed to a developer in an open change request).
2201
2353
  onSaveEntry={changesMode || branchFrozen ? undefined : onSaveEntry}
@@ -2214,6 +2366,16 @@ function CmsView({
2214
2366
  readOnlyNotice={branchFrozen ? "This branch is in an open change request and is read-only until it merges or is rejected." : undefined}
2215
2367
  readOnlyNoticeActionLabel="Learn more"
2216
2368
  onReadOnlyNoticeAction={branchFrozen ? openChangeRequestModal : undefined}
2369
+ // The protected-branch prompt. Opened by a refused save (not by the branch
2370
+ // being protected): a protected branch is perfectly readable and editable,
2371
+ // and interrupting before the author has decided to save would be noise.
2372
+ protectedBranch={protectedSave ? branch.name : undefined}
2373
+ protectedBranchDocument={protectedSave?.documentLabel}
2374
+ protectedBranchSuggestedName={protectedSave?.suggestedName}
2375
+ creatingProtectedBranch={creatingProtectedBranch}
2376
+ protectedBranchError={protectedBranchError}
2377
+ onCreateBranchAndSave={onCreateBranchAndSave}
2378
+ onCancelProtectedSave={onCancelProtectedSave}
2217
2379
  onDeleteEntry={onDeleteEntry}
2218
2380
  selectable={!changesMode && !!active?.canDelete && !active?.singleton}
2219
2381
  onBulkDelete={onBulkDelete}
@@ -0,0 +1,77 @@
1
+ import { useMemo } from "react";
2
+ import { useApolloClient, useQuery } from "@apollo/client";
3
+ import type { FormInfo, FormsApi, SubmissionInfo } from "@gogitcms/design-system";
4
+ import {
5
+ DELETE_FORM_SUBMISSIONS,
6
+ FORMS,
7
+ FORM_SUBMISSIONS,
8
+ FORM_SUBMISSION_COUNT,
9
+ } from "./queries";
10
+
11
+ // The Apollo implementation of the design system's forms seam. The DS owns the
12
+ // browsing UI and knows nothing about transport; this file is the only place
13
+ // form queries live on the web client — the same split media.ts draws.
14
+
15
+ // Submissions are read uncached. Unlike a content model's documents, they arrive
16
+ // from outside the editor at any moment, so a cached page would show an inbox
17
+ // that quietly stopped updating — the one thing an inbox must not do.
18
+ const NO_CACHE = { fetchPolicy: "no-cache" as const };
19
+
20
+ export function useFormsApi(
21
+ repositoryId?: string,
22
+ branchId?: string,
23
+ project?: string,
24
+ ): FormsApi | undefined {
25
+ const client = useApolloClient();
26
+ const skip = !repositoryId || !branchId;
27
+
28
+ // The form list comes from the branch's config plus a count per form. It is
29
+ // cached: the definitions change only on re-import, and the counts are a
30
+ // summary rather than the inbox itself.
31
+ const { data } = useQuery(FORMS, {
32
+ variables: { repositoryId, branchId, project: project ?? null },
33
+ skip,
34
+ });
35
+ const forms: FormInfo[] = data?.forms ?? [];
36
+
37
+ return useMemo(() => {
38
+ if (skip) return undefined;
39
+ // A branch whose config declares no forms has no Forms surface at all, which
40
+ // is what keeps the editor identical for every repository that has none.
41
+ if (forms.length === 0) return undefined;
42
+
43
+ const scope = { repositoryId, branchId, project: project ?? null };
44
+ return {
45
+ forms,
46
+ list: async ({ form, status, search, limit, offset }) => {
47
+ const res = await client.query({
48
+ query: FORM_SUBMISSIONS,
49
+ variables: {
50
+ ...scope,
51
+ form,
52
+ status: status ?? null,
53
+ search: search ?? null,
54
+ limit: limit ?? null,
55
+ offset: offset ?? null,
56
+ },
57
+ ...NO_CACHE,
58
+ });
59
+ return (res.data?.formSubmissions ?? []) as SubmissionInfo[];
60
+ },
61
+ count: async ({ form, status, search }) => {
62
+ const res = await client.query({
63
+ query: FORM_SUBMISSION_COUNT,
64
+ variables: { ...scope, form, status: status ?? null, search: search ?? null },
65
+ ...NO_CACHE,
66
+ });
67
+ return (res.data?.formSubmissionCount ?? 0) as number;
68
+ },
69
+ remove: async (ids) => {
70
+ await client.mutate({
71
+ mutation: DELETE_FORM_SUBMISSIONS,
72
+ variables: { repositoryId, ids },
73
+ });
74
+ },
75
+ };
76
+ }, [client, skip, forms, repositoryId, branchId, project]);
77
+ }
@@ -0,0 +1,50 @@
1
+ import { useMemo } from "react";
2
+ import { useApolloClient } from "@apollo/client";
3
+ import type { DocumentChange, DocumentVersion, HistoryApi } from "@gogitcms/design-system";
4
+ import { DOCUMENT_VERSION, DOCUMENT_VERSIONS } from "./queries";
5
+
6
+ // The Apollo implementation of the design system's document-history seam. The
7
+ // DS owns the version browser and knows nothing about transport; this file is
8
+ // the only place history queries live on the web client — the same split
9
+ // forms.ts and media.ts draw.
10
+
11
+ // History reads are cached normally. A commit is immutable, so a version fetched
12
+ // once can be re-shown for free — which is the whole point of clicking back up a
13
+ // list you have already read. What can change is the newest end of the list, and
14
+ // that is re-fetched whenever the pane is opened, because the DS mounts fresh.
15
+ const CACHED = { fetchPolicy: "cache-first" as const };
16
+
17
+ /**
18
+ * useHistoryApi builds the seam, or returns undefined when history is
19
+ * unavailable — which hides the affordance entirely rather than offering a
20
+ * button that errors.
21
+ *
22
+ * `available` is the deployment's answer to "is there a provider behind this
23
+ * editor". Local mode edits a working tree that may not even be a git
24
+ * repository, and has no GitHub App installation to read a commit log through.
25
+ */
26
+ export function useHistoryApi(repositoryId: string | undefined, available: boolean): HistoryApi | undefined {
27
+ const client = useApolloClient();
28
+
29
+ return useMemo(() => {
30
+ if (!repositoryId || !available) return undefined;
31
+ return {
32
+ list: async ({ documentId, limit, offset }) => {
33
+ const res = await client.query({
34
+ query: DOCUMENT_VERSIONS,
35
+ variables: { repositoryId, id: documentId, limit, offset },
36
+ ...CACHED,
37
+ });
38
+ return (res.data?.documentVersions ?? []) as DocumentVersion[];
39
+ },
40
+ get: async ({ documentId, sha }) => {
41
+ const res = await client.query({
42
+ query: DOCUMENT_VERSION,
43
+ variables: { repositoryId, id: documentId, sha },
44
+ ...CACHED,
45
+ });
46
+ return (res.data?.documentVersion ?? null) as DocumentChange | null;
47
+ },
48
+ };
49
+ }, [client, repositoryId, available]);
50
+ }
@@ -111,6 +111,51 @@ export const BRANCH_CHANGE = gql`
111
111
  }
112
112
  `;
113
113
 
114
+ // ---- document history -----------------------------------------------------
115
+ // One page of the commits that touched a document, newest first. Each page is a
116
+ // provider request, so the page size stays modest and the list pages as it
117
+ // scrolls rather than asking for everything at once.
118
+ export const DOCUMENT_VERSIONS = gql`
119
+ query DocumentVersions($repositoryId: ID!, $id: ID!, $limit: Int, $offset: Int) {
120
+ documentVersions(repositoryId: $repositoryId, id: $id, limit: $limit, offset: $offset) {
121
+ sha
122
+ shortSha
123
+ message
124
+ authorName
125
+ authorEmail
126
+ authoredAt
127
+ url
128
+ filesChanged
129
+ }
130
+ }
131
+ `;
132
+
133
+ // One version: the document as it was at that commit, diffed against what it is
134
+ // now. The selection shape is BranchChange's, deliberately — the history pane
135
+ // renders through the same ChangeDetail component the changes surface uses.
136
+ export const DOCUMENT_VERSION = gql`
137
+ query DocumentVersion($repositoryId: ID!, $id: ID!, $sha: String!) {
138
+ documentVersion(repositoryId: $repositoryId, id: $id, sha: $sha) {
139
+ sha
140
+ path
141
+ collection
142
+ label
143
+ status
144
+ added
145
+ removed
146
+ bodyBefore
147
+ bodyAfter
148
+ fields {
149
+ name
150
+ label
151
+ kind
152
+ before
153
+ after
154
+ }
155
+ }
156
+ }
157
+ `;
158
+
114
159
  // The merge run's fields, shared by the mutation, query and subscription so the
115
160
  // Apollo cache normalizes them onto one MergeRun by id.
116
161
  const MERGE_RUN_FIELDS = gql`
@@ -239,6 +284,43 @@ export const CREATE_BRANCH = gql`
239
284
  createBranch(repositoryId: $repositoryId, fromBranchId: $fromBranchId, name: $name) {
240
285
  id
241
286
  name
287
+ protected
288
+ }
289
+ }
290
+ `;
291
+
292
+ // The recovery from a BRANCH_PROTECTED save: one call that creates the branch,
293
+ // finds the same document on it (a document's cross-branch identity is its path,
294
+ // so it is a different row there) and applies the edit.
295
+ export const SAVE_DOCUMENT_ON_NEW_BRANCH = gql`
296
+ mutation SaveDocumentOnNewBranch(
297
+ $repositoryId: ID!
298
+ $fromBranchId: ID!
299
+ $name: String!
300
+ $documentId: ID!
301
+ $fields: JSON!
302
+ $body: String
303
+ ) {
304
+ saveDocumentOnNewBranch(
305
+ repositoryId: $repositoryId
306
+ fromBranchId: $fromBranchId
307
+ name: $name
308
+ documentId: $documentId
309
+ fields: $fields
310
+ body: $body
311
+ ) {
312
+ branch {
313
+ id
314
+ name
315
+ protected
316
+ }
317
+ document {
318
+ id
319
+ path
320
+ label
321
+ fields
322
+ body
323
+ }
242
324
  }
243
325
  }
244
326
  `;
@@ -589,3 +671,84 @@ export const RENAME_DOCUMENT = gql`
589
671
  }
590
672
  }
591
673
  `;
674
+
675
+ // ── Forms ───────────────────────────────────────────────────────────────────
676
+
677
+ export const FORMS = gql`
678
+ query Forms($repositoryId: ID!, $branchId: ID!, $project: String) {
679
+ forms(repositoryId: $repositoryId, branchId: $branchId, project: $project) {
680
+ name
681
+ label
682
+ description
683
+ fieldCount
684
+ submissionCount
685
+ lastSubmissionAt
686
+ versioned
687
+ storePath
688
+ canDelete
689
+ fields {
690
+ name
691
+ label
692
+ type
693
+ component
694
+ }
695
+ }
696
+ }
697
+ `;
698
+
699
+ export const FORM_SUBMISSIONS = gql`
700
+ query FormSubmissions(
701
+ $repositoryId: ID!
702
+ $branchId: ID!
703
+ $project: String
704
+ $form: String!
705
+ $status: String
706
+ $search: String
707
+ $limit: Int
708
+ $offset: Int
709
+ ) {
710
+ formSubmissions(
711
+ repositoryId: $repositoryId
712
+ branchId: $branchId
713
+ project: $project
714
+ form: $form
715
+ status: $status
716
+ search: $search
717
+ limit: $limit
718
+ offset: $offset
719
+ ) {
720
+ id
721
+ form
722
+ fields
723
+ submittedAt
724
+ status
725
+ path
726
+ }
727
+ }
728
+ `;
729
+
730
+ export const FORM_SUBMISSION_COUNT = gql`
731
+ query FormSubmissionCount(
732
+ $repositoryId: ID!
733
+ $branchId: ID!
734
+ $project: String
735
+ $form: String!
736
+ $status: String
737
+ $search: String
738
+ ) {
739
+ formSubmissionCount(
740
+ repositoryId: $repositoryId
741
+ branchId: $branchId
742
+ project: $project
743
+ form: $form
744
+ status: $status
745
+ search: $search
746
+ )
747
+ }
748
+ `;
749
+
750
+ export const DELETE_FORM_SUBMISSIONS = gql`
751
+ mutation DeleteFormSubmissions($repositoryId: ID!, $ids: [ID!]!) {
752
+ deleteFormSubmissions(repositoryId: $repositoryId, ids: $ids)
753
+ }
754
+ `;