@gogitcms/design-system 0.16.0-next.4 → 0.16.0-next.6
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/package.json +1 -1
- package/src/__tests__/ContentBrowser.history.test.tsx +209 -3
- package/src/__tests__/ContentBrowser.historycollab.test.tsx +433 -0
- package/src/components/ChangeDetail.tsx +84 -10
- package/src/components/CollabField.tsx +71 -0
- package/src/components/ContentBrowser.tsx +144 -38
- package/src/components/DocumentHistory.tsx +118 -2
- package/src/components/Notifications.tsx +146 -28
- package/src/components/primitives.tsx +46 -1
- package/src/icons.ts +1 -0
- package/src/index.ts +3 -2
|
@@ -6,7 +6,7 @@ import { Text } from "./Text";
|
|
|
6
6
|
import { Icon } from "./Icon";
|
|
7
7
|
import { Button, IconButton } from "./Button";
|
|
8
8
|
import { Input } from "./Input";
|
|
9
|
-
import { Avatar, Badge, DiffStat, Divider, SectionLabel } from "./primitives";
|
|
9
|
+
import { Avatar, Badge, CheckBox, DiffStat, Divider, SectionLabel } from "./primitives";
|
|
10
10
|
import { NavRow } from "./NavRow";
|
|
11
11
|
import { AppShell, MobileScreen, Pane, TopBar, ThemeToggle } from "./layout";
|
|
12
12
|
import { Segment, type SegmentItem } from "./Segment";
|
|
@@ -23,6 +23,7 @@ import {
|
|
|
23
23
|
PresenceField,
|
|
24
24
|
PresenceAvatars,
|
|
25
25
|
useCollabRegister,
|
|
26
|
+
writeCollabValue,
|
|
26
27
|
useCollabMirror,
|
|
27
28
|
useCollabSynced,
|
|
28
29
|
useCollabValue,
|
|
@@ -114,6 +115,12 @@ export type FieldSlotArgs = {
|
|
|
114
115
|
// CRDT and publish presence.
|
|
115
116
|
collab?: CollabApi;
|
|
116
117
|
path?: string;
|
|
118
|
+
// Bumped when the host replaces this field's value outright rather than the
|
|
119
|
+
// user editing it — restoring it from a past version. A controlled control
|
|
120
|
+
// needs nothing from this (it already re-renders with the new value), but the
|
|
121
|
+
// body editor does: in a live session it ignores `value` and mirrors the
|
|
122
|
+
// shared fragment, so "the value changed" is not a thing it can observe.
|
|
123
|
+
resetNonce?: number;
|
|
117
124
|
};
|
|
118
125
|
export type RenderField = (args: FieldSlotArgs) => React.ReactNode;
|
|
119
126
|
|
|
@@ -767,6 +774,11 @@ type FieldControlProps = {
|
|
|
767
774
|
// When this number changes, the field programmatically focuses itself (used to
|
|
768
775
|
// jump to a peer's field from its presence avatar).
|
|
769
776
|
focusSignal?: number;
|
|
777
|
+
// Bumped when this field's value was replaced by the host rather than typed —
|
|
778
|
+
// restored from a past version. Only the host-supplied body editor needs it
|
|
779
|
+
// (see FieldSlotArgs.resetNonce); every built-in control is controlled and
|
|
780
|
+
// re-renders from `value` on its own.
|
|
781
|
+
resetNonce?: number;
|
|
770
782
|
};
|
|
771
783
|
|
|
772
784
|
// The scalar type an array's `of` maps to when rendering item controls.
|
|
@@ -780,7 +792,7 @@ function ofToType(of?: string): string {
|
|
|
780
792
|
}
|
|
781
793
|
|
|
782
794
|
function FieldControl(props: FieldControlProps) {
|
|
783
|
-
const { field, value, onChange, renderField, readOnly = false, error, hideLabel, onOpenGroup, collab, path, focusSignal } = props;
|
|
795
|
+
const { field, value, onChange, renderField, readOnly = false, error, hideLabel, onOpenGroup, collab, path, focusSignal, resetNonce } = props;
|
|
784
796
|
const t = useTheme();
|
|
785
797
|
const label = hideLabel ? "" : field.label || field.name;
|
|
786
798
|
// A text field participates in live collaboration when the document has a
|
|
@@ -793,7 +805,7 @@ function FieldControl(props: FieldControlProps) {
|
|
|
793
805
|
|
|
794
806
|
// Host-supplied control (e.g. the markdown editor) takes precedence.
|
|
795
807
|
if (renderField) {
|
|
796
|
-
const custom = renderField({ field, value, onChange, readOnly, collab: collabText, path });
|
|
808
|
+
const custom = renderField({ field, value, onChange, readOnly, collab: collabText, path, resetNonce });
|
|
797
809
|
if (custom != null && custom !== false) {
|
|
798
810
|
return (
|
|
799
811
|
<View style={{ gap: t.space(2) }}>
|
|
@@ -2638,6 +2650,11 @@ function EntryDetail({
|
|
|
2638
2650
|
const canRename = !readOnly && !!onRename;
|
|
2639
2651
|
const canMove = !readOnly && !!onMove && glob.hierarchical;
|
|
2640
2652
|
const [prompt, setPrompt] = useState<"rename" | "move" | null>(null);
|
|
2653
|
+
// Per-field "your value was replaced" counters, bumped by a restore. Only the
|
|
2654
|
+
// host-supplied body editor reads them (see FieldSlotArgs.resetNonce); every
|
|
2655
|
+
// built-in control is controlled and needs no telling.
|
|
2656
|
+
const [resetNonces, setResetNonces] = useState<Record<string, number>>({});
|
|
2657
|
+
|
|
2641
2658
|
// Whether this column is showing the version browser instead of the form.
|
|
2642
2659
|
// Deliberately per-column and ephemeral: history is something you open, read
|
|
2643
2660
|
// and close, so it lives and dies with the column rather than in the URL.
|
|
@@ -2832,6 +2849,112 @@ function EntryDetail({
|
|
|
2832
2849
|
[commit],
|
|
2833
2850
|
);
|
|
2834
2851
|
|
|
2852
|
+
// Publish a value map the form did NOT arrive at by typing — a restore from a
|
|
2853
|
+
// past version, or a discard back to the saved document.
|
|
2854
|
+
//
|
|
2855
|
+
// Both used to write local state and stop there, which is only half the job in
|
|
2856
|
+
// a live session. The shared document is the source of truth there: every
|
|
2857
|
+
// collab control prefers its binding to the value handed to it, so a replaced
|
|
2858
|
+
// value was displayed for one frame and then overwritten by the room's
|
|
2859
|
+
// unchanged one, and no peer ever saw it. Anything that replaces values
|
|
2860
|
+
// wholesale has to come through here.
|
|
2861
|
+
//
|
|
2862
|
+
// `names` are the fields whose values were replaced; unchanged ones are left
|
|
2863
|
+
// alone so a replacement never churns the room (or nudges a peer's cursor)
|
|
2864
|
+
// over a value that did not move.
|
|
2865
|
+
const publishReplacement = useCallback(
|
|
2866
|
+
(previous: Record<string, unknown>, next: Record<string, unknown>, names: string[]) => {
|
|
2867
|
+
const moved = names.filter((name) => !sameValue(previous[name], next[name]));
|
|
2868
|
+
if (moved.length === 0) return;
|
|
2869
|
+
|
|
2870
|
+
if (entry.collab) {
|
|
2871
|
+
for (const name of moved) {
|
|
2872
|
+
// A body is a ProseMirror fragment rather than a field binding, so it
|
|
2873
|
+
// is not written here — its editor is told instead, by the nonce below.
|
|
2874
|
+
if (editFields.find((f) => f.name === name)?.source === "body") continue;
|
|
2875
|
+
writeCollabValue(entry.collab, name, previous[name], next[name]);
|
|
2876
|
+
}
|
|
2877
|
+
}
|
|
2878
|
+
|
|
2879
|
+
// Tell the replaced fields their value came from outside. Keyed per field
|
|
2880
|
+
// rather than one counter for the document: the body editor acts on this,
|
|
2881
|
+
// and replacing some unrelated field must not make it rebuild a body a
|
|
2882
|
+
// peer may be mid-sentence in.
|
|
2883
|
+
setResetNonces((prev) => {
|
|
2884
|
+
const bumped = { ...prev };
|
|
2885
|
+
for (const name of moved) bumped[name] = (bumped[name] ?? 0) + 1;
|
|
2886
|
+
return bumped;
|
|
2887
|
+
});
|
|
2888
|
+
},
|
|
2889
|
+
// editFields is derived from entry, captured by id.
|
|
2890
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
2891
|
+
[entry.id, entry.collab],
|
|
2892
|
+
);
|
|
2893
|
+
|
|
2894
|
+
// Values taken from a past version and put back into the form.
|
|
2895
|
+
//
|
|
2896
|
+
// It is an edit like any other: the restored fields land in `values`, the
|
|
2897
|
+
// document goes unsaved, and the author saves it (or discards it) themselves.
|
|
2898
|
+
// Nothing is written to git here — history is a place you read, and rewriting
|
|
2899
|
+
// the document from it should still pass through the same review a typed edit
|
|
2900
|
+
// does.
|
|
2901
|
+
const restoreValues = useCallback(
|
|
2902
|
+
(restored: { fields: Record<string, unknown>; body?: string | null }) => {
|
|
2903
|
+
const previous = valuesRef.current;
|
|
2904
|
+
const next = { ...previous, ...restored.fields };
|
|
2905
|
+
// The body is a field in the form like any other; which one it is comes
|
|
2906
|
+
// from the schema (`source: "body"`), the same mapping buildChange uses in
|
|
2907
|
+
// the other direction. A document whose model has no body field simply
|
|
2908
|
+
// has no body to restore.
|
|
2909
|
+
const bodyField = "body" in restored ? editFields.find((f) => f.source === "body") : undefined;
|
|
2910
|
+
if (bodyField) next[bodyField.name] = restored.body ?? "";
|
|
2911
|
+
|
|
2912
|
+
const replaced = Object.keys(restored.fields);
|
|
2913
|
+
if (bodyField) replaced.push(bodyField.name);
|
|
2914
|
+
|
|
2915
|
+
// Touched under the first restored name so a restore that leaves a
|
|
2916
|
+
// required field empty shows its error immediately, rather than looking
|
|
2917
|
+
// saveable until Save is pressed.
|
|
2918
|
+
//
|
|
2919
|
+
// Committed BEFORE publishing: a collab control observing the room echoes
|
|
2920
|
+
// what it sees back through onChange, and that echo builds its next map by
|
|
2921
|
+
// spreading the current one. Publishing first would have it spread the
|
|
2922
|
+
// pre-restore map.
|
|
2923
|
+
commit(next, replaced[0] ?? SYNTHETIC_BODY);
|
|
2924
|
+
publishReplacement(previous, next, replaced);
|
|
2925
|
+
// Drilled-into groups address the pre-restore shape; a restored group
|
|
2926
|
+
// object can have different children entirely.
|
|
2927
|
+
setGroupPath([]);
|
|
2928
|
+
},
|
|
2929
|
+
// editFields is derived from entry, which commit already captures by id.
|
|
2930
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
2931
|
+
[commit, entry.id, publishReplacement],
|
|
2932
|
+
);
|
|
2933
|
+
|
|
2934
|
+
// Throw away everything unsaved and go back to the document as stored.
|
|
2935
|
+
//
|
|
2936
|
+
// In a live session that is a change to the ROOM, not just to this screen: the
|
|
2937
|
+
// unsaved work is the collective state every client is looking at, and a
|
|
2938
|
+
// discard that only reverted the local form would leave the document it just
|
|
2939
|
+
// claimed to restore sitting in the CRDT, ready to come straight back.
|
|
2940
|
+
const discardEdits = useCallback(() => {
|
|
2941
|
+
const previous = valuesRef.current;
|
|
2942
|
+
const restored = seed();
|
|
2943
|
+
valuesRef.current = restored;
|
|
2944
|
+
setValues(restored);
|
|
2945
|
+
setTouched({});
|
|
2946
|
+
// The preview is rendering the draft being thrown away, and nothing else
|
|
2947
|
+
// will tell it otherwise: it is fed from edits, and a discard is the one
|
|
2948
|
+
// change to a document that produces no edit. Without this the preview
|
|
2949
|
+
// keeps showing work the author just deleted, until they type again.
|
|
2950
|
+
emitDraft(buildChange(entry.id, editFields, restored), entry);
|
|
2951
|
+
discard();
|
|
2952
|
+
publishReplacement(previous, restored, editFields.map((f) => f.name));
|
|
2953
|
+
setGroupPath([]);
|
|
2954
|
+
// seed/editFields derive from entry, captured by id.
|
|
2955
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
2956
|
+
}, [entry.id, discard, emitDraft, publishReplacement]);
|
|
2957
|
+
|
|
2835
2958
|
// A field edit within the active group-drill frame: write the value at the
|
|
2836
2959
|
// frame's nested path, then commit the whole map (buildChange serializes it).
|
|
2837
2960
|
const changeInFrame = useCallback(
|
|
@@ -2895,19 +3018,7 @@ function EntryDetail({
|
|
|
2895
3018
|
{saveHandler && dirty ? (
|
|
2896
3019
|
<Pressable
|
|
2897
3020
|
testID="discard-changes"
|
|
2898
|
-
onPress={
|
|
2899
|
-
const restored = seed();
|
|
2900
|
-
valuesRef.current = restored;
|
|
2901
|
-
setValues(restored);
|
|
2902
|
-
setTouched({});
|
|
2903
|
-
// The preview is rendering the draft that is being thrown away,
|
|
2904
|
-
// and nothing else will tell it otherwise: it is fed from edits,
|
|
2905
|
-
// and a discard is the one change to a document that produces no
|
|
2906
|
-
// edit. Without this the preview keeps showing work the author
|
|
2907
|
-
// just deleted, until they type again.
|
|
2908
|
-
emitDraft(buildChange(entry.id, editFields, restored), entry);
|
|
2909
|
-
discard();
|
|
2910
|
-
}}
|
|
3021
|
+
onPress={discardEdits}
|
|
2911
3022
|
style={{ paddingHorizontal: t.space(2), paddingVertical: t.space(1) }}
|
|
2912
3023
|
>
|
|
2913
3024
|
<Text variant="monoSm" color="tertiary">Discard</Text>
|
|
@@ -2973,6 +3084,21 @@ function EntryDetail({
|
|
|
2973
3084
|
documentPath={entry.path}
|
|
2974
3085
|
history={history}
|
|
2975
3086
|
onClose={() => setShowHistory(false)}
|
|
3087
|
+
// Restoring needs somewhere for the values to go: a document that is
|
|
3088
|
+
// read-only, or that this host never wired a save for, gets the
|
|
3089
|
+
// history with no selection boxes at all rather than a form it can
|
|
3090
|
+
// dirty and never save. (saveHandler is already undefined when
|
|
3091
|
+
// readOnly, so this covers both.)
|
|
3092
|
+
onRestore={
|
|
3093
|
+
!saveHandler
|
|
3094
|
+
? undefined
|
|
3095
|
+
: (restored) => {
|
|
3096
|
+
restoreValues(restored);
|
|
3097
|
+
// Back to the form, which is where the restored values now
|
|
3098
|
+
// are and where the decision to keep them is made.
|
|
3099
|
+
setShowHistory(false);
|
|
3100
|
+
}
|
|
3101
|
+
}
|
|
2976
3102
|
/>
|
|
2977
3103
|
) : /* Keyed by entry.id so controls (incl. the markdown editor) remount with
|
|
2978
3104
|
fresh initial values (and the scroll resets to top) when the selected
|
|
@@ -3015,6 +3141,7 @@ function EntryDetail({
|
|
|
3015
3141
|
collab={entry.collab}
|
|
3016
3142
|
path={[...groupPath, f.name].join(".")}
|
|
3017
3143
|
focusSignal={focusTarget && focusTarget.name === f.name ? focusTarget.nonce : undefined}
|
|
3144
|
+
resetNonce={resetNonces[f.name]}
|
|
3018
3145
|
/>
|
|
3019
3146
|
))}
|
|
3020
3147
|
</ScrollView>
|
|
@@ -3094,27 +3221,6 @@ function useMultiSelect(resetKey: string) {
|
|
|
3094
3221
|
return { checked, toggle, clear, checkedIds, checkedFolders, toggleFolder, checkedFolderKeys };
|
|
3095
3222
|
}
|
|
3096
3223
|
|
|
3097
|
-
// CheckBox is the row-selection control for multi-select.
|
|
3098
|
-
function CheckBox({ value, onToggle }: { value: boolean; onToggle: () => void }) {
|
|
3099
|
-
const t = useTheme();
|
|
3100
|
-
return (
|
|
3101
|
-
<Pressable
|
|
3102
|
-
// Stop the press from bubbling to the row's open handler (the checkbox
|
|
3103
|
-
// sits inside the row Pressable) so toggling never also opens the entry.
|
|
3104
|
-
onPress={(e?: { stopPropagation?: () => void }) => { e?.stopPropagation?.(); onToggle(); }}
|
|
3105
|
-
testID="entry-checkbox"
|
|
3106
|
-
style={{
|
|
3107
|
-
width: 18, height: 18, borderRadius: t.radius.sm, borderWidth: 1,
|
|
3108
|
-
borderColor: value ? t.color.borderStrong : t.color.borderDefault,
|
|
3109
|
-
backgroundColor: value ? t.color.surfaceInverted : t.color.surfaceRaised,
|
|
3110
|
-
alignItems: "center", justifyContent: "center",
|
|
3111
|
-
}}
|
|
3112
|
-
>
|
|
3113
|
-
{value ? <Icon name="check" size={12} color={t.color.textInverted} /> : null}
|
|
3114
|
-
</Pressable>
|
|
3115
|
-
);
|
|
3116
|
-
}
|
|
3117
|
-
|
|
3118
3224
|
// SelectionBar appears above the entry list when rows are checked; it offers a
|
|
3119
3225
|
// bulk move and/or delete plus a clear action (each shown only when wired).
|
|
3120
3226
|
function SelectionBar({ count, onMove, onDelete, onClear }: { count: number; onMove?: () => void; onDelete?: () => void; onClear: () => void }) {
|
|
@@ -3257,7 +3363,7 @@ function EntriesList({
|
|
|
3257
3363
|
<View style={{ flexDirection: "row", alignItems: "center" }}>
|
|
3258
3364
|
{showChecks ? (
|
|
3259
3365
|
<View style={{ paddingLeft: t.space(4) }}>
|
|
3260
|
-
<CheckBox value={!!checked?.[e.id]} onToggle={() => onToggleEntry?.(e.id)} />
|
|
3366
|
+
<CheckBox value={!!checked?.[e.id]} onToggle={() => onToggleEntry?.(e.id)} testID="entry-checkbox" />
|
|
3261
3367
|
</View>
|
|
3262
3368
|
) : null}
|
|
3263
3369
|
<View style={{ flex: 1 }}>
|
|
@@ -4,7 +4,8 @@ import { useTheme } from "../ThemeProvider";
|
|
|
4
4
|
import { Text } from "./Text";
|
|
5
5
|
import { Icon } from "./Icon";
|
|
6
6
|
import { Badge } from "./primitives";
|
|
7
|
-
import {
|
|
7
|
+
import { Button } from "./Button";
|
|
8
|
+
import { BODY_FIELD, ChangeDetail, type DocumentChange } from "./ChangeDetail";
|
|
8
9
|
import { firstLine, relativeTime, type DocumentVersion, type HistoryApi } from "../history";
|
|
9
10
|
|
|
10
11
|
/** How many versions a page holds. One page is one provider request. */
|
|
@@ -18,6 +19,22 @@ export type DocumentHistoryProps = {
|
|
|
18
19
|
history: HistoryApi;
|
|
19
20
|
/** Leaves history and returns the column to the editor. */
|
|
20
21
|
onClose: () => void;
|
|
22
|
+
/**
|
|
23
|
+
* Put a past version's values for the chosen fields back into the editor.
|
|
24
|
+
*
|
|
25
|
+
* It restores into the FORM, not into git and not straight to a save: the
|
|
26
|
+
* document becomes unsaved with those fields rolled back, and the author
|
|
27
|
+
* reviews and saves it like any other edit. Reading history and rewriting the
|
|
28
|
+
* document are two different acts, and only one of them should be one click
|
|
29
|
+
* from a list you are still browsing.
|
|
30
|
+
*
|
|
31
|
+
* `body` is present only when the body was among the chosen fields — null is
|
|
32
|
+
* a real restorable value (a document that had no body), so its absence has
|
|
33
|
+
* to be expressed by the key being missing rather than by null.
|
|
34
|
+
*
|
|
35
|
+
* Omitted (a read-only document) → no boxes and no restore bar, only reading.
|
|
36
|
+
*/
|
|
37
|
+
onRestore?: (restored: { fields: Record<string, unknown>; body?: string | null }) => void;
|
|
21
38
|
};
|
|
22
39
|
|
|
23
40
|
/**
|
|
@@ -30,7 +47,7 @@ export type DocumentHistoryProps = {
|
|
|
30
47
|
* and close, not a place the editor navigates to. Nothing here survives closing
|
|
31
48
|
* the pane, and nothing about it is in the URL.
|
|
32
49
|
*/
|
|
33
|
-
export function DocumentHistory({ documentId, documentPath, history, onClose }: DocumentHistoryProps) {
|
|
50
|
+
export function DocumentHistory({ documentId, documentPath, history, onClose, onRestore }: DocumentHistoryProps) {
|
|
34
51
|
const t = useTheme();
|
|
35
52
|
|
|
36
53
|
const [versions, setVersions] = useState<DocumentVersion[]>([]);
|
|
@@ -43,6 +60,14 @@ export function DocumentHistory({ documentId, documentPath, history, onClose }:
|
|
|
43
60
|
const [detail, setDetail] = useState<DocumentChange | undefined>(undefined);
|
|
44
61
|
const [loadingDetail, setLoadingDetail] = useState(false);
|
|
45
62
|
const [detailError, setDetailError] = useState<string | null>(null);
|
|
63
|
+
// Which fields the reader has picked to take from this version. Cleared on
|
|
64
|
+
// every version change below: a tick means "restore THIS value", so carrying
|
|
65
|
+
// it to another version would silently repoint it at a different one.
|
|
66
|
+
const [picked, setPicked] = useState<readonly string[]>([]);
|
|
67
|
+
const togglePick = useCallback(
|
|
68
|
+
(name: string) => setPicked((p) => (p.includes(name) ? p.filter((n) => n !== name) : [...p, name])),
|
|
69
|
+
[],
|
|
70
|
+
);
|
|
46
71
|
|
|
47
72
|
// Guards every async result against a document switch or an unmount: the pane
|
|
48
73
|
// is inside a column whose document can change under it, and a page that
|
|
@@ -66,6 +91,7 @@ export function DocumentHistory({ documentId, documentPath, history, onClose }:
|
|
|
66
91
|
setExhausted(false);
|
|
67
92
|
setSelected(null);
|
|
68
93
|
setDetail(undefined);
|
|
94
|
+
setPicked([]);
|
|
69
95
|
history
|
|
70
96
|
.list({ documentId, limit: PAGE, offset: 0 })
|
|
71
97
|
.then((page) => {
|
|
@@ -110,6 +136,10 @@ export function DocumentHistory({ documentId, documentPath, history, onClose }:
|
|
|
110
136
|
|
|
111
137
|
// The selected version's document + diff.
|
|
112
138
|
useEffect(() => {
|
|
139
|
+
// A tick names a value, not a field: it means "take THIS version's title".
|
|
140
|
+
// Carrying the selection to the next version would keep the ticks while
|
|
141
|
+
// silently repointing them at different values.
|
|
142
|
+
setPicked([]);
|
|
113
143
|
if (!selected) {
|
|
114
144
|
setDetail(undefined);
|
|
115
145
|
return;
|
|
@@ -137,6 +167,29 @@ export function DocumentHistory({ documentId, documentPath, history, onClose }:
|
|
|
137
167
|
};
|
|
138
168
|
}, [documentId, history, selected]);
|
|
139
169
|
|
|
170
|
+
// Restore is offered only for a document the caller can write AND a version
|
|
171
|
+
// that differs from it: a version identical to the current document has no
|
|
172
|
+
// field whose old value is anything but the value already there.
|
|
173
|
+
const canRestore = !!onRestore && !!detail && detail.status !== "U";
|
|
174
|
+
|
|
175
|
+
const restore = useCallback(() => {
|
|
176
|
+
if (!onRestore || !detail) return;
|
|
177
|
+
const chosen = new Set(picked);
|
|
178
|
+
const fields: Record<string, unknown> = {};
|
|
179
|
+
for (const f of detail.fields) {
|
|
180
|
+
if (!chosen.has(f.name)) continue;
|
|
181
|
+
// `before` is this version's value, which is the whole point — and it is
|
|
182
|
+
// undefined for a field the version did not have. Restoring that has to
|
|
183
|
+
// mean "clear it", so it is written as undefined rather than skipped.
|
|
184
|
+
fields[f.name] = f.before;
|
|
185
|
+
}
|
|
186
|
+
const restored: { fields: Record<string, unknown>; body?: string | null } = { fields };
|
|
187
|
+
// The key is present only when the body was picked: null is a real value to
|
|
188
|
+
// restore (a document that had no body), so absence cannot be spelled null.
|
|
189
|
+
if (chosen.has(BODY_FIELD)) restored.body = detail.bodyBefore ?? null;
|
|
190
|
+
onRestore(restored);
|
|
191
|
+
}, [onRestore, detail, picked]);
|
|
192
|
+
|
|
140
193
|
return (
|
|
141
194
|
<View testID="document-history" style={{ flex: 1, minHeight: 0, flexDirection: "row" }}>
|
|
142
195
|
{/* Left: the document at the selected version, annotated with what has
|
|
@@ -153,8 +206,11 @@ export function DocumentHistory({ documentId, documentPath, history, onClose }:
|
|
|
153
206
|
change={detail}
|
|
154
207
|
loading={loadingDetail}
|
|
155
208
|
emptyMessage={loading ? "Loading history…" : "Select a version"}
|
|
209
|
+
selectedFields={picked}
|
|
210
|
+
onToggleField={canRestore ? togglePick : undefined}
|
|
156
211
|
/>
|
|
157
212
|
)}
|
|
213
|
+
{canRestore ? <RestoreBar count={picked.length} onRestore={restore} onClear={() => setPicked([])} /> : null}
|
|
158
214
|
</View>
|
|
159
215
|
|
|
160
216
|
{/* Right: the versions. */}
|
|
@@ -237,6 +293,66 @@ export function DocumentHistory({ documentId, documentPath, history, onClose }:
|
|
|
237
293
|
);
|
|
238
294
|
}
|
|
239
295
|
|
|
296
|
+
/**
|
|
297
|
+
* The restore action, docked under the diff.
|
|
298
|
+
*
|
|
299
|
+
* Always mounted while a writable version is selected, rather than appearing
|
|
300
|
+
* once something is ticked: a bar that materialises on the first tick shifts
|
|
301
|
+
* the diff under the hand that just ticked it, and the disabled button is what
|
|
302
|
+
* tells you the boxes are for something in the first place.
|
|
303
|
+
*/
|
|
304
|
+
function RestoreBar({
|
|
305
|
+
count,
|
|
306
|
+
onRestore,
|
|
307
|
+
onClear,
|
|
308
|
+
}: {
|
|
309
|
+
count: number;
|
|
310
|
+
onRestore: () => void;
|
|
311
|
+
onClear: () => void;
|
|
312
|
+
}) {
|
|
313
|
+
const t = useTheme();
|
|
314
|
+
return (
|
|
315
|
+
<View
|
|
316
|
+
testID="restore-bar"
|
|
317
|
+
style={{
|
|
318
|
+
flexDirection: "row",
|
|
319
|
+
alignItems: "center",
|
|
320
|
+
gap: t.space(2),
|
|
321
|
+
paddingHorizontal: t.space(4),
|
|
322
|
+
paddingVertical: t.space(2),
|
|
323
|
+
borderTopWidth: 1,
|
|
324
|
+
borderTopColor: t.color.borderSubtle,
|
|
325
|
+
backgroundColor: t.color.surfaceSunken,
|
|
326
|
+
}}
|
|
327
|
+
>
|
|
328
|
+
<Text variant="monoSm" color="tertiary" style={{ flex: 1 }}>
|
|
329
|
+
{count === 0
|
|
330
|
+
? "Tick a field to take its earlier value"
|
|
331
|
+
: count === 1
|
|
332
|
+
? "1 field selected"
|
|
333
|
+
: `${count} fields selected`}
|
|
334
|
+
</Text>
|
|
335
|
+
{count > 0 ? (
|
|
336
|
+
<Pressable onPress={onClear} testID="restore-clear" style={{ paddingHorizontal: t.space(2), paddingVertical: t.space(1) }}>
|
|
337
|
+
<Text variant="monoSm" color="tertiary">Clear</Text>
|
|
338
|
+
</Pressable>
|
|
339
|
+
) : null}
|
|
340
|
+
<Button
|
|
341
|
+
testID="restore-apply"
|
|
342
|
+
// Named for what it does to the thing in front of you. It does not write
|
|
343
|
+
// to git and does not save — it puts the values back in the form, and
|
|
344
|
+
// the author still has to save them.
|
|
345
|
+
title={count > 1 ? `Restore ${count} fields` : "Restore field"}
|
|
346
|
+
size="sm"
|
|
347
|
+
variant={count > 0 ? "primary" : "default"}
|
|
348
|
+
disabled={count === 0}
|
|
349
|
+
onPress={onRestore}
|
|
350
|
+
style={{ alignSelf: "center" }}
|
|
351
|
+
/>
|
|
352
|
+
</View>
|
|
353
|
+
);
|
|
354
|
+
}
|
|
355
|
+
|
|
240
356
|
function VersionRow({
|
|
241
357
|
version,
|
|
242
358
|
selected,
|
|
@@ -4,10 +4,11 @@ import { useTheme, useThemeMode, ThemeScope } from "../ThemeProvider";
|
|
|
4
4
|
import { lightTheme, darkTheme } from "../theme";
|
|
5
5
|
import { Text } from "./Text";
|
|
6
6
|
import { Icon } from "./Icon";
|
|
7
|
+
import type { IconName } from "../icons";
|
|
7
8
|
import { Spinner } from "./Spinner";
|
|
8
9
|
|
|
9
10
|
// One notification, shaped for display. Transport-agnostic: the frontend/mobile
|
|
10
|
-
// apps map GraphQL rows and the desktop
|
|
11
|
+
// apps map GraphQL rows and the desktop and dashboard map REST rows into this.
|
|
11
12
|
export type NotificationItem = {
|
|
12
13
|
id: string;
|
|
13
14
|
title: string;
|
|
@@ -16,8 +17,39 @@ export type NotificationItem = {
|
|
|
16
17
|
read: boolean;
|
|
17
18
|
createdAt: string; // ISO 8601
|
|
18
19
|
author?: string; // display name/email of who caused it, when known
|
|
20
|
+
// What produced it — picks the icon. Unknown kinds fall back to the bell.
|
|
21
|
+
kind?: NotificationKind | string;
|
|
22
|
+
// Where it happened, for a list that spans several repositories/projects.
|
|
23
|
+
// Omit whichever the list is already scoped to.
|
|
24
|
+
repository?: string;
|
|
25
|
+
project?: string;
|
|
19
26
|
};
|
|
20
27
|
|
|
28
|
+
export type NotificationKind = "import" | "export" | "merge" | "change_request" | "member" | "branch" | "project";
|
|
29
|
+
|
|
30
|
+
// The icon for a notification kind. Exported so hosts that draw their own rows
|
|
31
|
+
// (a scope picker's section headers, say) agree with the list.
|
|
32
|
+
export function notificationKindIcon(kind?: string): IconName {
|
|
33
|
+
switch (kind) {
|
|
34
|
+
case "import":
|
|
35
|
+
return "download";
|
|
36
|
+
case "export":
|
|
37
|
+
return "upload";
|
|
38
|
+
case "merge":
|
|
39
|
+
return "gitMerge";
|
|
40
|
+
case "change_request":
|
|
41
|
+
return "gitPullRequest";
|
|
42
|
+
case "member":
|
|
43
|
+
return "user";
|
|
44
|
+
case "branch":
|
|
45
|
+
return "gitBranch";
|
|
46
|
+
case "project":
|
|
47
|
+
return "folder";
|
|
48
|
+
default:
|
|
49
|
+
return "bell";
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
|
|
21
53
|
// relativeTime renders a compact age ("just now", "5m", "3h", "2d").
|
|
22
54
|
function relativeTime(iso: string): string {
|
|
23
55
|
const then = Date.parse(iso);
|
|
@@ -32,7 +64,65 @@ function relativeTime(iso: string): string {
|
|
|
32
64
|
return `${days}d`;
|
|
33
65
|
}
|
|
34
66
|
|
|
35
|
-
//
|
|
67
|
+
// InlineCode renders a sentence whose backticked spans (`main`, `#12`'s
|
|
68
|
+
// branch) are set in the mono face, so a title like "Created `zyx` branch"
|
|
69
|
+
// reads as prose around a name rather than as punctuation. An unmatched
|
|
70
|
+
// backtick is left as typed.
|
|
71
|
+
export function InlineCode({
|
|
72
|
+
text,
|
|
73
|
+
variant = "sm",
|
|
74
|
+
weight,
|
|
75
|
+
color,
|
|
76
|
+
numberOfLines,
|
|
77
|
+
testID,
|
|
78
|
+
}: {
|
|
79
|
+
text: string;
|
|
80
|
+
variant?: "sm" | "body" | "monoSm" | "xs";
|
|
81
|
+
weight?: "regular" | "medium" | "semibold";
|
|
82
|
+
color?: string;
|
|
83
|
+
numberOfLines?: number;
|
|
84
|
+
testID?: string;
|
|
85
|
+
}) {
|
|
86
|
+
const t = useTheme();
|
|
87
|
+
const parts = text.split("`");
|
|
88
|
+
// An even number of parts means an odd number of backticks: not a pair, so
|
|
89
|
+
// render the text untouched.
|
|
90
|
+
if (parts.length % 2 === 0) {
|
|
91
|
+
return (
|
|
92
|
+
<Text variant={variant} weight={weight} color={color} numberOfLines={numberOfLines} testID={testID}>
|
|
93
|
+
{text}
|
|
94
|
+
</Text>
|
|
95
|
+
);
|
|
96
|
+
}
|
|
97
|
+
return (
|
|
98
|
+
<Text variant={variant} weight={weight} color={color} numberOfLines={numberOfLines} testID={testID}>
|
|
99
|
+
{parts.map((part, i) =>
|
|
100
|
+
i % 2 === 1 ? (
|
|
101
|
+
<Text
|
|
102
|
+
key={i}
|
|
103
|
+
variant={variant}
|
|
104
|
+
weight={weight}
|
|
105
|
+
color={color}
|
|
106
|
+
mono
|
|
107
|
+
style={{
|
|
108
|
+
backgroundColor: t.color.surfaceSunken,
|
|
109
|
+
borderRadius: t.radius.sm,
|
|
110
|
+
paddingHorizontal: 3,
|
|
111
|
+
}}
|
|
112
|
+
>
|
|
113
|
+
{part}
|
|
114
|
+
</Text>
|
|
115
|
+
) : (
|
|
116
|
+
part
|
|
117
|
+
),
|
|
118
|
+
)}
|
|
119
|
+
</Text>
|
|
120
|
+
);
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
// A single notification row: a kind icon, the title (unread rows in bold, error
|
|
124
|
+
// rows tinted), the body, and a context line — where it happened and who did
|
|
125
|
+
// it — with the age on the right.
|
|
36
126
|
function NotificationRow({
|
|
37
127
|
item,
|
|
38
128
|
divided,
|
|
@@ -44,12 +134,16 @@ function NotificationRow({
|
|
|
44
134
|
}) {
|
|
45
135
|
const t = useTheme();
|
|
46
136
|
const titleColor = item.level === "error" ? t.color.diffDelFg : t.color.textPrimary;
|
|
137
|
+
const context = [item.repository, item.project].filter(Boolean).join(" · ");
|
|
138
|
+
const meta = [context, item.author ? `by ${item.author}` : ""].filter(Boolean).join(" · ");
|
|
47
139
|
return (
|
|
48
140
|
<Pressable
|
|
49
141
|
onPress={onPress}
|
|
142
|
+
disabled={!onPress}
|
|
143
|
+
testID={`notification-${item.id}`}
|
|
50
144
|
style={({ pressed, hovered }: { pressed: boolean; hovered?: boolean }) => ({
|
|
51
145
|
flexDirection: "row",
|
|
52
|
-
gap: t.space(
|
|
146
|
+
gap: t.space(3),
|
|
53
147
|
paddingHorizontal: t.space(4),
|
|
54
148
|
paddingVertical: t.space(3),
|
|
55
149
|
borderBottomWidth: divided ? 1 : 0,
|
|
@@ -57,33 +151,57 @@ function NotificationRow({
|
|
|
57
151
|
backgroundColor: hovered || pressed ? t.color.surfaceHover : "transparent",
|
|
58
152
|
})}
|
|
59
153
|
>
|
|
60
|
-
<View
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
<
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
color={
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
154
|
+
<View style={{ width: 28, height: 28, alignItems: "center", justifyContent: "center", position: "relative" }}>
|
|
155
|
+
<View
|
|
156
|
+
style={{
|
|
157
|
+
width: 28,
|
|
158
|
+
height: 28,
|
|
159
|
+
borderRadius: t.radius.pill,
|
|
160
|
+
alignItems: "center",
|
|
161
|
+
justifyContent: "center",
|
|
162
|
+
backgroundColor: item.level === "error" ? t.color.diffDelBg : t.color.surfaceSunken,
|
|
163
|
+
}}
|
|
164
|
+
>
|
|
165
|
+
<Icon
|
|
166
|
+
name={notificationKindIcon(item.kind)}
|
|
167
|
+
size={14}
|
|
168
|
+
color={item.level === "error" ? t.color.diffDelFg : t.color.textSecondary}
|
|
169
|
+
/>
|
|
170
|
+
</View>
|
|
171
|
+
{/* Unread dot, riding on the icon's corner. */}
|
|
172
|
+
{item.read ? null : (
|
|
173
|
+
<View
|
|
174
|
+
testID="notification-unread"
|
|
175
|
+
style={{
|
|
176
|
+
position: "absolute",
|
|
177
|
+
top: -1,
|
|
178
|
+
right: -1,
|
|
179
|
+
width: 8,
|
|
180
|
+
height: 8,
|
|
181
|
+
borderRadius: t.radius.pill,
|
|
182
|
+
backgroundColor: t.color.diffDelFg,
|
|
183
|
+
borderWidth: 1,
|
|
184
|
+
borderColor: t.color.surfaceRaised,
|
|
185
|
+
}}
|
|
186
|
+
/>
|
|
187
|
+
)}
|
|
188
|
+
</View>
|
|
189
|
+
<View style={{ flex: 1, gap: 2, minWidth: 0 }}>
|
|
190
|
+
<View style={{ flexDirection: "row", alignItems: "flex-start", gap: t.space(2) }}>
|
|
191
|
+
<View style={{ flex: 1, minWidth: 0 }}>
|
|
192
|
+
<InlineCode
|
|
193
|
+
text={item.title}
|
|
194
|
+
variant="sm"
|
|
195
|
+
weight={item.read ? "medium" : "semibold"}
|
|
196
|
+
color={titleColor}
|
|
197
|
+
numberOfLines={2}
|
|
198
|
+
/>
|
|
199
|
+
</View>
|
|
80
200
|
<Text variant="monoSm" color="tertiary">{relativeTime(item.createdAt)}</Text>
|
|
81
201
|
</View>
|
|
82
|
-
{item.body ?
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
{item.author ? (
|
|
86
|
-
<Text variant="monoSm" color="tertiary" numberOfLines={1}>{`by ${item.author}`}</Text>
|
|
202
|
+
{item.body ? <InlineCode text={item.body} variant="monoSm" color={t.color.textSecondary} numberOfLines={2} /> : null}
|
|
203
|
+
{meta ? (
|
|
204
|
+
<Text variant="monoSm" color="tertiary" numberOfLines={1}>{meta}</Text>
|
|
87
205
|
) : null}
|
|
88
206
|
</View>
|
|
89
207
|
</Pressable>
|