sp2react 0.1.0__py3-none-any.whl
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.
- sp2react/__init__.py +18 -0
- sp2react/agents/__init__.py +0 -0
- sp2react/agents/core.py +146 -0
- sp2react/agents/prompts/dbmap.md +28 -0
- sp2react/agents/prompts/fix.md +11 -0
- sp2react/agents/prompts/handler.md +22 -0
- sp2react/agents/prompts/page.md +40 -0
- sp2react/agents/prompts/review.md +21 -0
- sp2react/agents/prompts/spec.md +79 -0
- sp2react/agents/tasks.py +374 -0
- sp2react/analysis/__init__.py +0 -0
- sp2react/analysis/aspx.py +254 -0
- sp2react/analysis/behaviors.py +357 -0
- sp2react/analysis/fields.py +152 -0
- sp2react/analysis/inventory.py +314 -0
- sp2react/analysis/jsscan.py +330 -0
- sp2react/checks.py +105 -0
- sp2react/cli.py +188 -0
- sp2react/config.py +172 -0
- sp2react/dbmap.py +245 -0
- sp2react/llm/__init__.py +0 -0
- sp2react/llm/providers.py +219 -0
- sp2react/migrate.py +255 -0
- sp2react/net.py +60 -0
- sp2react/pipeline.py +583 -0
- sp2react/scaffold/__init__.py +0 -0
- sp2react/scaffold/generate.py +279 -0
- sp2react/scaffold/rule_vectors.json +3826 -0
- sp2react/scaffold/templates/backend/app/__init__.py +0 -0
- sp2react/scaffold/templates/backend/app/api.py +293 -0
- sp2react/scaffold/templates/backend/app/auth.py +33 -0
- sp2react/scaffold/templates/backend/app/custom/__init__.py +22 -0
- sp2react/scaffold/templates/backend/app/main.py +47 -0
- sp2react/scaffold/templates/backend/app/odata.py +185 -0
- sp2react/scaffold/templates/backend/app/rules.py +256 -0
- sp2react/scaffold/templates/backend/app/sp_repo.py +159 -0
- sp2react/scaffold/templates/backend/app/sql_repo.py +437 -0
- sp2react/scaffold/templates/backend/app/store.py +224 -0
- sp2react/scaffold/templates/backend/requirements.txt +8 -0
- sp2react/scaffold/templates/backend/tests/conftest.py +10 -0
- sp2react/scaffold/templates/backend/tests/test_rule_vectors.py +38 -0
- sp2react/scaffold/templates/frontend/index.html +12 -0
- sp2react/scaffold/templates/frontend/package.json +24 -0
- sp2react/scaffold/templates/frontend/scripts/check-rules.ts +23 -0
- sp2react/scaffold/templates/frontend/src/api.ts +88 -0
- sp2react/scaffold/templates/frontend/src/components/sp/Banner.tsx +4 -0
- sp2react/scaffold/templates/frontend/src/components/sp/DataTable.tsx +38 -0
- sp2react/scaffold/templates/frontend/src/components/sp/Form.tsx +252 -0
- sp2react/scaffold/templates/frontend/src/components/sp/GenericPage.tsx +28 -0
- sp2react/scaffold/templates/frontend/src/components/sp/Layout.tsx +54 -0
- sp2react/scaffold/templates/frontend/src/components/sp/LegacyHtml.tsx +9 -0
- sp2react/scaffold/templates/frontend/src/components/sp/ListView.tsx +59 -0
- sp2react/scaffold/templates/frontend/src/components/sp/Tiles.tsx +21 -0
- sp2react/scaffold/templates/frontend/src/components/sp/fields.tsx +132 -0
- sp2react/scaffold/templates/frontend/src/custom/types.ts +10 -0
- sp2react/scaffold/templates/frontend/src/main.tsx +14 -0
- sp2react/scaffold/templates/frontend/src/runtime/format.ts +36 -0
- sp2react/scaffold/templates/frontend/src/runtime/rules.ts +225 -0
- sp2react/scaffold/templates/frontend/src/runtime/spec.ts +102 -0
- sp2react/scaffold/templates/frontend/src/runtime/user.tsx +21 -0
- sp2react/scaffold/templates/frontend/src/screens/LegacyRedirect.tsx +27 -0
- sp2react/scaffold/templates/frontend/src/screens/ListFormScreen.tsx +33 -0
- sp2react/scaffold/templates/frontend/src/styles/sp.css +95 -0
- sp2react/scaffold/templates/frontend/src/vite-env.d.ts +1 -0
- sp2react/scaffold/templates/frontend/tsconfig.json +18 -0
- sp2react/scaffold/templates/frontend/vite.config.ts +7 -0
- sp2react/sharepoint/__init__.py +0 -0
- sp2react/sharepoint/client.py +210 -0
- sp2react/sharepoint/extract.py +217 -0
- sp2react/spec/__init__.py +0 -0
- sp2react/spec/base.py +80 -0
- sp2react/spec/coverage.py +191 -0
- sp2react/spec/model.py +133 -0
- sp2react-0.1.0.dist-info/METADATA +238 -0
- sp2react-0.1.0.dist-info/RECORD +79 -0
- sp2react-0.1.0.dist-info/WHEEL +5 -0
- sp2react-0.1.0.dist-info/entry_points.txt +2 -0
- sp2react-0.1.0.dist-info/licenses/LICENSE +21 -0
- sp2react-0.1.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import type { ReactNode } from "react";
|
|
2
|
+
|
|
3
|
+
export interface Tile {
|
|
4
|
+
id?: string;
|
|
5
|
+
label: string;
|
|
6
|
+
value: ReactNode;
|
|
7
|
+
alert?: boolean;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
export function Tiles({ tiles, className = "db-tiles" }: { tiles: Tile[]; className?: string }) {
|
|
11
|
+
return (
|
|
12
|
+
<div className={className}>
|
|
13
|
+
{tiles.map((t, i) => (
|
|
14
|
+
<div key={t.id ?? i} id={t.id} className={`db-tile${t.alert ? " db-alert" : ""}`}>
|
|
15
|
+
<span className="db-count">{t.value}</span>
|
|
16
|
+
{t.label}
|
|
17
|
+
</div>
|
|
18
|
+
))}
|
|
19
|
+
</div>
|
|
20
|
+
);
|
|
21
|
+
}
|
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
// Field controls that render like SharePoint 2013/2016 list form controls.
|
|
2
|
+
// `title` attributes match SharePoint's ("<Label>" / "<Label> Required Field") so parity tests can
|
|
3
|
+
// use the same selectors the legacy jQuery used.
|
|
4
|
+
import { useEffect, useRef, useState } from "react";
|
|
5
|
+
import { api } from "../../api";
|
|
6
|
+
import { formatValue } from "../../runtime/format";
|
|
7
|
+
import type { Field } from "../../runtime/spec";
|
|
8
|
+
|
|
9
|
+
export interface Option {
|
|
10
|
+
id: number;
|
|
11
|
+
title: string;
|
|
12
|
+
[k: string]: any;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
interface Props {
|
|
16
|
+
field: Field;
|
|
17
|
+
value: any;
|
|
18
|
+
onChange: (v: any) => void;
|
|
19
|
+
onBlur?: () => void;
|
|
20
|
+
options?: Option[];
|
|
21
|
+
disabled?: boolean;
|
|
22
|
+
required?: boolean;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
function PeoplePicker({ field, value, onChange, onBlur, disabled, required }: Props) {
|
|
26
|
+
const multi = !!field.multi;
|
|
27
|
+
const selected: any[] = multi ? (value ?? []) : value ? [value] : [];
|
|
28
|
+
const [text, setText] = useState("");
|
|
29
|
+
const [hits, setHits] = useState<any[]>([]);
|
|
30
|
+
const timer = useRef<number | undefined>(undefined);
|
|
31
|
+
useEffect(() => {
|
|
32
|
+
window.clearTimeout(timer.current);
|
|
33
|
+
if (text.trim().length < 2) { setHits([]); return; }
|
|
34
|
+
timer.current = window.setTimeout(() => { api.users(text.trim()).then(setHits).catch(() => setHits([])); }, 250);
|
|
35
|
+
}, [text]);
|
|
36
|
+
const pick = (u: any) => {
|
|
37
|
+
const v = { id: u.id, login: u.login, title: u.title };
|
|
38
|
+
onChange(multi ? [...selected.filter((s) => s.id !== u.id), v] : v);
|
|
39
|
+
setText("");
|
|
40
|
+
setHits([]);
|
|
41
|
+
};
|
|
42
|
+
return (
|
|
43
|
+
<div className={`sp-peoplepicker${disabled ? " is-disabled" : ""}`} title={`${field.label}${required ? " Required Field" : ""}`}>
|
|
44
|
+
{selected.map((s) => (
|
|
45
|
+
<span key={s.id} className="ms-entity-resolved">
|
|
46
|
+
{s.title}
|
|
47
|
+
{!disabled && <button type="button" aria-label={`Remove ${s.title}`} onClick={() => onChange(multi ? selected.filter((x) => x.id !== s.id) : null)}>×</button>}
|
|
48
|
+
</span>
|
|
49
|
+
))}
|
|
50
|
+
{!disabled && (multi || selected.length === 0) && (
|
|
51
|
+
<input className="ms-inputuserfield" value={text} onChange={(e) => setText(e.target.value)} onBlur={onBlur}
|
|
52
|
+
placeholder="Enter a name or email address..." aria-label={field.label} />
|
|
53
|
+
)}
|
|
54
|
+
{hits.length > 0 && (
|
|
55
|
+
<ul className="sp-picker-hits" role="listbox">
|
|
56
|
+
{hits.map((h) => <li key={h.id} role="option" aria-selected={false} onMouseDown={() => pick(h)}>{h.title} <span className="sp-muted">{h.email}</span></li>)}
|
|
57
|
+
</ul>
|
|
58
|
+
)}
|
|
59
|
+
</div>
|
|
60
|
+
);
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
export function FieldControl(props: Props) {
|
|
64
|
+
const { field, value, onChange, onBlur, options, disabled, required } = props;
|
|
65
|
+
const title = `${field.label}${required ? " Required Field" : ""}`;
|
|
66
|
+
const common = { title, disabled, onBlur, id: `fld-${field.name}`, name: field.name };
|
|
67
|
+
switch (field.type) {
|
|
68
|
+
case "note":
|
|
69
|
+
return <textarea {...common} className="ms-long" rows={field.lines ?? 6} value={value ?? ""} onChange={(e) => onChange(e.target.value)} />;
|
|
70
|
+
case "choice":
|
|
71
|
+
if (field.format === "radio")
|
|
72
|
+
return (
|
|
73
|
+
<span className="sp-radios" title={title}>
|
|
74
|
+
{(field.choices ?? []).map((c) => (
|
|
75
|
+
<label key={c}><input type="radio" name={field.name} disabled={disabled} checked={value === c} onChange={() => onChange(c)} /> {c}</label>
|
|
76
|
+
))}
|
|
77
|
+
</span>
|
|
78
|
+
);
|
|
79
|
+
return (
|
|
80
|
+
<select {...common} value={value ?? ""} onChange={(e) => onChange(e.target.value || null)}>
|
|
81
|
+
{(!required || field.default === undefined) && <option value=""></option>}
|
|
82
|
+
{(field.choices ?? []).map((c) => <option key={c} value={c}>{c}</option>)}
|
|
83
|
+
</select>
|
|
84
|
+
);
|
|
85
|
+
case "multichoice": {
|
|
86
|
+
const vals: string[] = value ?? [];
|
|
87
|
+
return (
|
|
88
|
+
<span className="sp-checks" title={title}>
|
|
89
|
+
{(field.choices ?? []).map((c) => (
|
|
90
|
+
<label key={c}>
|
|
91
|
+
<input type="checkbox" disabled={disabled} checked={vals.includes(c)}
|
|
92
|
+
onChange={(e) => onChange(e.target.checked ? [...vals, c] : vals.filter((x) => x !== c))} /> {c}
|
|
93
|
+
</label>
|
|
94
|
+
))}
|
|
95
|
+
</span>
|
|
96
|
+
);
|
|
97
|
+
}
|
|
98
|
+
case "lookup": {
|
|
99
|
+
const id = value && typeof value === "object" ? value.id : value ?? "";
|
|
100
|
+
return (
|
|
101
|
+
<select {...common} value={id ?? ""} onChange={(e) => {
|
|
102
|
+
const o = (options ?? []).find((x) => String(x.id) === e.target.value);
|
|
103
|
+
onChange(o ? { id: o.id, title: o.title } : null);
|
|
104
|
+
}}>
|
|
105
|
+
{!required && <option value="">(None)</option>}
|
|
106
|
+
{required && !id && <option value=""></option>}
|
|
107
|
+
{(options ?? []).map((o) => <option key={o.id} value={o.id}>{o.title}</option>)}
|
|
108
|
+
</select>
|
|
109
|
+
);
|
|
110
|
+
}
|
|
111
|
+
case "user":
|
|
112
|
+
return <PeoplePicker {...props} />;
|
|
113
|
+
case "date":
|
|
114
|
+
return <input {...common} type="date" className="ms-input" value={value ?? ""} onChange={(e) => onChange(e.target.value || null)} />;
|
|
115
|
+
case "datetime":
|
|
116
|
+
return <input {...common} type="datetime-local" className="ms-input" value={value ? String(value).slice(0, 16) : ""} onChange={(e) => onChange(e.target.value || null)} />;
|
|
117
|
+
case "number":
|
|
118
|
+
case "currency":
|
|
119
|
+
return (
|
|
120
|
+
<span className="sp-number">
|
|
121
|
+
{field.type === "currency" && <span className="sp-currency">$</span>}
|
|
122
|
+
<input {...common} type="text" inputMode="decimal" className="ms-input" size={11} value={value ?? ""} onChange={(e) => onChange(e.target.value)} />
|
|
123
|
+
</span>
|
|
124
|
+
);
|
|
125
|
+
case "boolean":
|
|
126
|
+
return <input {...common} type="checkbox" checked={!!value} onChange={(e) => onChange(e.target.checked)} />;
|
|
127
|
+
case "calculated":
|
|
128
|
+
return <span>{formatValue(field, value)}</span>;
|
|
129
|
+
default:
|
|
130
|
+
return <input {...common} type="text" className="ms-long" maxLength={field.max_length} value={value ?? ""} onChange={(e) => onChange(e.target.value)} />;
|
|
131
|
+
}
|
|
132
|
+
}
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
import type { Api } from "../api";
|
|
2
|
+
import type { Ctx, RuleError } from "../runtime/rules";
|
|
3
|
+
import type { Values } from "../runtime/spec";
|
|
4
|
+
|
|
5
|
+
// Contract for agent-written behaviour that spec rules cannot express (rule kind "custom").
|
|
6
|
+
export interface Handler {
|
|
7
|
+
onLoad?: (ctx: Ctx, api: Api) => void;
|
|
8
|
+
onChange?: (field: string, ctx: Ctx) => Values | void;
|
|
9
|
+
validate?: (ctx: Ctx) => RuleError[];
|
|
10
|
+
}
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import React from "react";
|
|
2
|
+
import ReactDOM from "react-dom/client";
|
|
3
|
+
import { BrowserRouter } from "react-router-dom";
|
|
4
|
+
import App from "./App";
|
|
5
|
+
import "./styles/sp.css";
|
|
6
|
+
import "./styles/legacy.css";
|
|
7
|
+
|
|
8
|
+
ReactDOM.createRoot(document.getElementById("root")!).render(
|
|
9
|
+
<React.StrictMode>
|
|
10
|
+
<BrowserRouter>
|
|
11
|
+
<App />
|
|
12
|
+
</BrowserRouter>
|
|
13
|
+
</React.StrictMode>
|
|
14
|
+
);
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
// SharePoint (en-US) display formats, so read-only values look the same as before.
|
|
2
|
+
import type { Field } from "./spec";
|
|
3
|
+
|
|
4
|
+
export function formatDate(v: unknown, withTime = false): string {
|
|
5
|
+
if (!v) return "";
|
|
6
|
+
const s = String(v);
|
|
7
|
+
const d = /^\d{4}-\d{2}-\d{2}$/.test(s) ? new Date(`${s}T00:00:00`) : new Date(s);
|
|
8
|
+
if (Number.isNaN(d.getTime())) return s;
|
|
9
|
+
const date = `${d.getMonth() + 1}/${d.getDate()}/${d.getFullYear()}`;
|
|
10
|
+
if (!withTime) return date;
|
|
11
|
+
let h = d.getHours();
|
|
12
|
+
const ap = h >= 12 ? "PM" : "AM";
|
|
13
|
+
h = h % 12 || 12;
|
|
14
|
+
return `${date} ${h}:${String(d.getMinutes()).padStart(2, "0")} ${ap}`;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export function formatCurrency(v: unknown): string {
|
|
18
|
+
if (v === null || v === undefined || v === "") return "";
|
|
19
|
+
const n = typeof v === "number" ? v : parseFloat(String(v).replace(/[$,]/g, ""));
|
|
20
|
+
return Number.isNaN(n) ? String(v) : n.toLocaleString("en-US", { style: "currency", currency: "USD" });
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export function formatValue(f: Field | undefined, v: any): string {
|
|
24
|
+
if (v === null || v === undefined || v === "") return "";
|
|
25
|
+
if (Array.isArray(v)) return v.map((x) => formatValue(f, x)).join("; ");
|
|
26
|
+
if (typeof v === "object") return v.title ?? String(v.id ?? "");
|
|
27
|
+
if (!f) return String(v);
|
|
28
|
+
switch (f.type) {
|
|
29
|
+
case "boolean": return v ? "Yes" : "No";
|
|
30
|
+
case "currency": return formatCurrency(v);
|
|
31
|
+
case "number": return typeof v === "number" ? v.toLocaleString("en-US") : String(v);
|
|
32
|
+
case "date": return formatDate(v);
|
|
33
|
+
case "datetime": return formatDate(v, true);
|
|
34
|
+
default: return String(v);
|
|
35
|
+
}
|
|
36
|
+
}
|
|
@@ -0,0 +1,225 @@
|
|
|
1
|
+
// Spec rule engine (browser side). Mirrors backend/app/rules.py; both run the same test vectors.
|
|
2
|
+
import type { Condition, Field, Mode, Rule, User, Values } from "./spec";
|
|
3
|
+
|
|
4
|
+
export const REQUIRED_MESSAGE = "You can't leave this blank.";
|
|
5
|
+
|
|
6
|
+
export interface Ctx {
|
|
7
|
+
mode: Mode;
|
|
8
|
+
values: Values;
|
|
9
|
+
original?: Values;
|
|
10
|
+
user?: User | null;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export interface FieldState {
|
|
14
|
+
visible: boolean;
|
|
15
|
+
readonly: boolean;
|
|
16
|
+
required: boolean;
|
|
17
|
+
requiredRule?: Rule;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export interface RuleError {
|
|
21
|
+
field?: string | null;
|
|
22
|
+
message: string;
|
|
23
|
+
presentation: string;
|
|
24
|
+
rule: string;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export function isEmpty(v: unknown): boolean {
|
|
28
|
+
if (v === null || v === undefined || v === "") return true;
|
|
29
|
+
if (Array.isArray(v)) return v.length === 0;
|
|
30
|
+
if (typeof v === "object") {
|
|
31
|
+
const id = (v as { id?: unknown }).id;
|
|
32
|
+
return id === null || id === undefined || id === 0 || id === "";
|
|
33
|
+
}
|
|
34
|
+
return false;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function scalar(v: unknown, against?: unknown): unknown {
|
|
38
|
+
if (v !== null && typeof v === "object" && !Array.isArray(v)) {
|
|
39
|
+
const o = v as { id?: unknown; title?: unknown };
|
|
40
|
+
if (typeof against === "number") return o.id;
|
|
41
|
+
return "title" in o ? o.title : o.id;
|
|
42
|
+
}
|
|
43
|
+
return v;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function numOrDate(v: unknown): unknown {
|
|
47
|
+
if (typeof v === "string" && /^\s*\$?-?[\d,]+(\.\d+)?\s*$/.test(v)) return parseFloat(v.replace(/[$,]/g, ""));
|
|
48
|
+
return v;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function cmp(a0: unknown, op: string, b0: unknown): boolean {
|
|
52
|
+
const a = numOrDate(scalar(a0, b0)) as number | string | boolean | null | undefined;
|
|
53
|
+
const b = numOrDate(b0) as number | string | boolean | null | undefined;
|
|
54
|
+
if (op === "eq") return a === b;
|
|
55
|
+
if (op === "ne") return a !== b;
|
|
56
|
+
if (a === null || a === undefined || b === null || b === undefined || a === "") return false;
|
|
57
|
+
if (typeof a !== typeof b) return false;
|
|
58
|
+
if (op === "gt") return a > b;
|
|
59
|
+
if (op === "gte") return a >= b;
|
|
60
|
+
if (op === "lt") return a < b;
|
|
61
|
+
if (op === "lte") return a <= b;
|
|
62
|
+
throw new Error(`unknown operator ${op}`);
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
export function getValue(values: Values, path: string): unknown {
|
|
66
|
+
const [head, rest] = path.includes(".") ? [path.slice(0, path.indexOf(".")), path.slice(path.indexOf(".") + 1)] : [path, ""];
|
|
67
|
+
const v = values[head];
|
|
68
|
+
if (rest && v && typeof v === "object") {
|
|
69
|
+
const o = v as Record<string, unknown>;
|
|
70
|
+
return rest in o ? o[rest] : o[rest.toLowerCase()];
|
|
71
|
+
}
|
|
72
|
+
return v;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
export function evaluate(cond: Condition | null | undefined, ctx: Ctx): boolean {
|
|
76
|
+
if (!cond) return true;
|
|
77
|
+
const c = cond as Record<string, any>;
|
|
78
|
+
if ("all" in c) return (c.all as Condition[]).every((x) => evaluate(x, ctx));
|
|
79
|
+
if ("any" in c) return (c.any as Condition[]).some((x) => evaluate(x, ctx));
|
|
80
|
+
if ("not" in c) return !evaluate(c.not as Condition, ctx);
|
|
81
|
+
if ("role" in c) return (ctx.user?.groups ?? []).includes(c.role);
|
|
82
|
+
if ("mode" in c) return (Array.isArray(c.mode) ? c.mode : [c.mode]).includes(ctx.mode);
|
|
83
|
+
if ("field" in c) {
|
|
84
|
+
const v = getValue(ctx.values ?? {}, c.field);
|
|
85
|
+
let result = true;
|
|
86
|
+
for (const [op, arg] of Object.entries(c)) {
|
|
87
|
+
if (op === "field") continue;
|
|
88
|
+
let ok: boolean;
|
|
89
|
+
if (op === "empty") ok = isEmpty(v) === Boolean(arg);
|
|
90
|
+
else if (op === "notEmpty") ok = !isEmpty(v) === Boolean(arg);
|
|
91
|
+
else if (op === "changed") ok = (scalar(v) !== scalar(getValue(ctx.original ?? {}, c.field))) === Boolean(arg);
|
|
92
|
+
else if (op === "in") ok = (arg as unknown[]).includes(scalar(v, (arg as unknown[])[0]));
|
|
93
|
+
else if (op === "nin") ok = !(arg as unknown[]).includes(scalar(v, (arg as unknown[])[0]));
|
|
94
|
+
else ok = cmp(v, op, arg);
|
|
95
|
+
result = result && ok;
|
|
96
|
+
}
|
|
97
|
+
return result;
|
|
98
|
+
}
|
|
99
|
+
throw new Error(`unknown condition ${JSON.stringify(cond)}`);
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
export const applies = (r: Rule, mode: Mode) => !r.modes || r.modes.length === 0 || r.modes.includes(mode);
|
|
103
|
+
|
|
104
|
+
export function rulesFor(rules: Rule[], kind: string, field: string | null, mode: Mode): Rule[] {
|
|
105
|
+
return rules.filter((r) => r.kind === kind && (field === null || r.field === field) && applies(r, mode));
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
export function fieldState(field: Field, rules: Rule[], ctx: Ctx): FieldState {
|
|
109
|
+
const name = field.name;
|
|
110
|
+
const mode = ctx.mode;
|
|
111
|
+
// visible rules: those for this mode must all hold; a field whose visible rules are all scoped to
|
|
112
|
+
// other modes is hidden in this one (e.g. Status shown only on edit/display)
|
|
113
|
+
const allVis = rules.filter((r) => r.kind === "visible" && r.field === name);
|
|
114
|
+
const visRules = allVis.filter((r) => applies(r, mode));
|
|
115
|
+
let visible = visRules.length ? visRules.every((r) => evaluate(r.when, ctx)) : !allVis.length || !allVis.every((r) => r.modes && r.modes.length);
|
|
116
|
+
if (field.type === "calculated" && mode !== "display") visible = false;
|
|
117
|
+
const readonly = mode === "display" || Boolean(field.read_only) || rulesFor(rules, "readonly", name, mode).some((r) => evaluate(r.when, ctx));
|
|
118
|
+
const required = Boolean(field.required) && mode !== "display" && !readonly;
|
|
119
|
+
const requiredRule = rulesFor(rules, "required", name, mode).find((r) => evaluate(r.when, ctx));
|
|
120
|
+
return { visible, readonly, required: required || (requiredRule !== undefined && visible), requiredRule };
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
export function resolveToken(token: unknown, user: User | null | undefined, today?: string): unknown {
|
|
124
|
+
if (token === "@me") return user ? { id: user.id, login: user.login, title: user.title } : null;
|
|
125
|
+
if (token === "@today") return today ?? new Date().toLocaleDateString("en-CA");
|
|
126
|
+
return token;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
export function defaults(fields: Field[], rules: Rule[], user: User | null | undefined, today?: string): Values {
|
|
130
|
+
const out: Values = {};
|
|
131
|
+
for (const f of fields) if (f.default !== undefined) out[f.name] = resolveToken(f.default, user, today);
|
|
132
|
+
for (const r of rulesFor(rules, "default", null, "new")) out[r.field as string] = resolveToken(r.params?.value, user, today);
|
|
133
|
+
return out;
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
export function fillTemplate(text: string, values: Values): string {
|
|
137
|
+
return (text ?? "").replace(/\{([\w.]+)(?:\|(lower|upper))?\}/g, (_m: string, path: string, filter?: string) => {
|
|
138
|
+
const v = scalar(getValue(values, path));
|
|
139
|
+
if (v === null || v === undefined) return "";
|
|
140
|
+
const out = String(v);
|
|
141
|
+
return filter === "lower" ? out.toLowerCase() : filter === "upper" ? out.toUpperCase() : out;
|
|
142
|
+
});
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
export function validate(fields: Field[], rules: Rule[], ctx: Ctx): RuleError[] {
|
|
146
|
+
const errors: RuleError[] = [];
|
|
147
|
+
const values = ctx.values ?? {};
|
|
148
|
+
const mode = ctx.mode;
|
|
149
|
+
const states: Record<string, FieldState> = {};
|
|
150
|
+
for (const f of fields) states[f.name] = fieldState(f, rules, ctx);
|
|
151
|
+
|
|
152
|
+
for (const f of fields) {
|
|
153
|
+
const st = states[f.name];
|
|
154
|
+
const v = values[f.name];
|
|
155
|
+
if (!st.visible || st.readonly) continue;
|
|
156
|
+
if (f.required && isEmpty(v)) {
|
|
157
|
+
errors.push({ field: f.name, message: REQUIRED_MESSAGE, presentation: "inline", rule: "schema" });
|
|
158
|
+
continue;
|
|
159
|
+
}
|
|
160
|
+
if (isEmpty(v)) continue;
|
|
161
|
+
if (f.max_length && typeof v === "string" && v.length > f.max_length)
|
|
162
|
+
errors.push({ field: f.name, message: `The value of this field may not contain more than ${f.max_length} characters.`, presentation: "inline", rule: "schema" });
|
|
163
|
+
if (f.type === "number" || f.type === "currency") {
|
|
164
|
+
const n = numOrDate(v);
|
|
165
|
+
if (typeof n !== "number" || Number.isNaN(n)) errors.push({ field: f.name, message: "The value of this field must be a number.", presentation: "inline", rule: "schema" });
|
|
166
|
+
else {
|
|
167
|
+
if (f.min !== undefined && n < f.min) errors.push({ field: f.name, message: `The value of this field must be greater than or equal to ${f.min}.`, presentation: "inline", rule: "schema" });
|
|
168
|
+
if (f.max !== undefined && n > f.max) errors.push({ field: f.name, message: `The value of this field must be less than or equal to ${f.max}.`, presentation: "inline", rule: "schema" });
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
if (f.type === "choice" && f.choices && f.choices.length && !f.fill_in && !f.choices.includes(v as string))
|
|
172
|
+
errors.push({ field: f.name, message: "Select a valid value.", presentation: "inline", rule: "schema" });
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
for (const r of rules) {
|
|
176
|
+
if (!applies(r, mode) || mode === "display") continue;
|
|
177
|
+
const fname = r.field ?? null;
|
|
178
|
+
const st = fname ? states[fname] : undefined;
|
|
179
|
+
if (st && (!st.visible || st.readonly) && r.kind !== "lock_item") continue;
|
|
180
|
+
if (r.when && !evaluate(r.when, ctx)) continue;
|
|
181
|
+
const v = fname ? values[fname] : undefined;
|
|
182
|
+
const p = (r.params ?? {}) as Record<string, any>;
|
|
183
|
+
let failed = false;
|
|
184
|
+
if (r.kind === "required") failed = isEmpty(v);
|
|
185
|
+
else if (r.kind === "compare") {
|
|
186
|
+
const other = values[p.other];
|
|
187
|
+
failed = isEmpty(v) || isEmpty(other) ? false : !cmp(v, p.op, scalar(other));
|
|
188
|
+
} else if (r.kind === "length") failed = !(p.allow_empty && isEmpty(v)) && !cmp(String(v ?? "").length, p.op, p.value);
|
|
189
|
+
else if (r.kind === "range") failed = !isEmpty(v) && !cmp(v, p.op, p.value);
|
|
190
|
+
else if (r.kind === "pattern") failed = !isEmpty(v) && !new RegExp(`^(?:${p.regex})$`).test(String(v));
|
|
191
|
+
if (failed) errors.push({ field: fname, message: fillTemplate(r.message ?? REQUIRED_MESSAGE, values), presentation: r.presentation ?? "inline", rule: r.id });
|
|
192
|
+
}
|
|
193
|
+
const seen = new Set<string>();
|
|
194
|
+
return errors.filter((e) => {
|
|
195
|
+
const key = e.field ? `${e.field}|${e.presentation}` : e.rule;
|
|
196
|
+
if (seen.has(key)) return false;
|
|
197
|
+
seen.add(key);
|
|
198
|
+
return true;
|
|
199
|
+
});
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
export function lock(rules: Rule[], ctx: Ctx): Rule | undefined {
|
|
203
|
+
return rulesFor(rules, "lock_item", null, ctx.mode).find((r) => evaluate(r.when, ctx));
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
export function setOnSave(rules: Rule[], ctx: Ctx): Values {
|
|
207
|
+
const out: Values = {};
|
|
208
|
+
for (const r of rulesFor(rules, "set_on_save", null, ctx.mode)) if (evaluate(r.when, ctx)) out[r.field as string] = resolveToken(r.params?.value, ctx.user);
|
|
209
|
+
return out;
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
export function banners(rules: Rule[], ctx: Ctx): Rule[] {
|
|
213
|
+
return rulesFor(rules, "banner", null, ctx.mode).filter((r) => evaluate(r.when, ctx));
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
// Fields hidden by a visible rule with params.clear lose their value (legacy: hide row + .val("")).
|
|
217
|
+
export function clearHidden(fields: Field[], rules: Rule[], ctx: Ctx): Values {
|
|
218
|
+
const out: Values = {};
|
|
219
|
+
if (ctx.mode === "display") return out;
|
|
220
|
+
for (const f of fields) {
|
|
221
|
+
const clears = rules.some((r) => r.kind === "visible" && r.field === f.name && r.params?.clear);
|
|
222
|
+
if (clears && !fieldState(f, rules, ctx).visible && !isEmpty(ctx.values?.[f.name])) out[f.name] = null;
|
|
223
|
+
}
|
|
224
|
+
return out;
|
|
225
|
+
}
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
// Types of the screen spec produced by sp2react (see sp2react/spec/model.py).
|
|
2
|
+
export type Mode = "new" | "edit" | "display";
|
|
3
|
+
export type Values = Record<string, any>;
|
|
4
|
+
|
|
5
|
+
export type Condition =
|
|
6
|
+
| { all: Condition[] }
|
|
7
|
+
| { any: Condition[] }
|
|
8
|
+
| { not: Condition }
|
|
9
|
+
| { role: string }
|
|
10
|
+
| { mode: Mode | Mode[] }
|
|
11
|
+
| ({ field: string } & Partial<Record<"eq" | "ne" | "gt" | "gte" | "lt" | "lte", unknown>> &
|
|
12
|
+
Partial<Record<"in" | "nin", unknown[]>> & Partial<Record<"empty" | "notEmpty" | "changed", boolean>>);
|
|
13
|
+
|
|
14
|
+
export interface Field {
|
|
15
|
+
name: string;
|
|
16
|
+
label: string;
|
|
17
|
+
type: "text" | "note" | "choice" | "multichoice" | "lookup" | "user" | "date" | "datetime" | "number" | "currency" | "boolean" | "url" | "calculated" | "taxonomy";
|
|
18
|
+
required?: boolean;
|
|
19
|
+
description?: string;
|
|
20
|
+
max_length?: number;
|
|
21
|
+
choices?: string[];
|
|
22
|
+
format?: "dropdown" | "radio" | "checkboxes";
|
|
23
|
+
fill_in?: boolean;
|
|
24
|
+
lookup?: { list: string; field: string };
|
|
25
|
+
multi?: boolean;
|
|
26
|
+
min?: number;
|
|
27
|
+
max?: number;
|
|
28
|
+
lines?: number;
|
|
29
|
+
read_only?: boolean;
|
|
30
|
+
default?: unknown;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export interface Rule {
|
|
34
|
+
id: string;
|
|
35
|
+
kind: string;
|
|
36
|
+
field?: string | null;
|
|
37
|
+
list?: string | null;
|
|
38
|
+
modes?: Mode[];
|
|
39
|
+
when?: Condition | null;
|
|
40
|
+
message?: string | null;
|
|
41
|
+
presentation?: "inline" | "alert" | "banner" | "confirm";
|
|
42
|
+
params?: Record<string, any>;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export interface Endpoint {
|
|
46
|
+
id: string;
|
|
47
|
+
method: string;
|
|
48
|
+
path: string;
|
|
49
|
+
purpose: string;
|
|
50
|
+
list?: string | null;
|
|
51
|
+
query?: Record<string, any>;
|
|
52
|
+
roles?: string[];
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export interface Component {
|
|
56
|
+
id: string;
|
|
57
|
+
kind: "form" | "tiles" | "table" | "list_view" | "banner" | "html" | "actions" | "custom";
|
|
58
|
+
title?: string | null;
|
|
59
|
+
region?: string | null;
|
|
60
|
+
order?: number;
|
|
61
|
+
data?: string[];
|
|
62
|
+
props?: Record<string, any>;
|
|
63
|
+
roles?: string[];
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
export interface ListSpec {
|
|
67
|
+
title: string;
|
|
68
|
+
primary?: boolean;
|
|
69
|
+
fields: Field[];
|
|
70
|
+
attachments?: boolean;
|
|
71
|
+
views?: Record<string, { fields: string[]; title?: string; rowLimit?: number }>;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
export interface Spec {
|
|
75
|
+
version: number;
|
|
76
|
+
screen: {
|
|
77
|
+
id: string;
|
|
78
|
+
kind: "list-form" | "page";
|
|
79
|
+
title: string;
|
|
80
|
+
route: string;
|
|
81
|
+
legacy_urls: string[];
|
|
82
|
+
list?: string | null;
|
|
83
|
+
modes: Mode[];
|
|
84
|
+
components: Component[];
|
|
85
|
+
};
|
|
86
|
+
lists: Record<string, ListSpec>;
|
|
87
|
+
rules: Rule[];
|
|
88
|
+
endpoints: Endpoint[];
|
|
89
|
+
roles: string[];
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
export interface User {
|
|
93
|
+
id: number;
|
|
94
|
+
login: string;
|
|
95
|
+
title: string;
|
|
96
|
+
email?: string;
|
|
97
|
+
groups: string[];
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
export function rulesForList(spec: Spec, list: string): Rule[] {
|
|
101
|
+
return spec.rules.filter((r) => (r.list ?? spec.screen.list) === list);
|
|
102
|
+
}
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import { createContext, useContext, useEffect, useState, type ReactNode } from "react";
|
|
2
|
+
import { api } from "../api";
|
|
3
|
+
import type { User } from "./spec";
|
|
4
|
+
|
|
5
|
+
const Ctx = createContext<{ user: User | null; error: string | null; reload: () => void }>({ user: null, error: null, reload: () => {} });
|
|
6
|
+
|
|
7
|
+
export function UserProvider({ children }: { children: ReactNode }) {
|
|
8
|
+
const [user, setUser] = useState<User | null>(null);
|
|
9
|
+
const [error, setError] = useState<string | null>(null);
|
|
10
|
+
const [n, setN] = useState(0);
|
|
11
|
+
useEffect(() => {
|
|
12
|
+
api.me().then((u) => { setUser(u); setError(null); }).catch((e) => setError(e.message));
|
|
13
|
+
}, [n]);
|
|
14
|
+
return <Ctx.Provider value={{ user, error, reload: () => setN((x) => x + 1) }}>{children}</Ctx.Provider>;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export const useUser = () => useContext(Ctx);
|
|
18
|
+
|
|
19
|
+
export function inGroup(user: User | null, group: string): boolean {
|
|
20
|
+
return !!user && user.groups.includes(group);
|
|
21
|
+
}
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
// Old SharePoint URLs (bookmarks, emails, links in other sites) keep working:
|
|
2
|
+
// /sites/x/Lists/Y/EditForm.aspx?ID=3&Source=... -> /lists/y/edit/3?Source=...
|
|
3
|
+
import { Navigate, useLocation } from "react-router-dom";
|
|
4
|
+
import type { Spec } from "../runtime/spec";
|
|
5
|
+
|
|
6
|
+
export function LegacyRedirect({ specs }: { specs: Spec[] }) {
|
|
7
|
+
const loc = useLocation();
|
|
8
|
+
const path = decodeURIComponent(loc.pathname).toLowerCase();
|
|
9
|
+
const q = new URLSearchParams(loc.search);
|
|
10
|
+
for (const s of specs) {
|
|
11
|
+
for (const u of s.screen.legacy_urls) {
|
|
12
|
+
if (u.toLowerCase() !== path) continue;
|
|
13
|
+
const file = u.split("/").pop()!.toLowerCase();
|
|
14
|
+
const id = q.get("ID") ?? q.get("id");
|
|
15
|
+
const src = q.get("Source");
|
|
16
|
+
const keep = src ? `?Source=${encodeURIComponent(src.replace(/^https?:\/\/[^/]+/, ""))}` : "";
|
|
17
|
+
if (s.screen.kind === "list-form") {
|
|
18
|
+
if (file.startsWith("newform")) return <Navigate replace to={`${s.screen.route}/new${keep}`} />;
|
|
19
|
+
if (file.startsWith("editform") && id) return <Navigate replace to={`${s.screen.route}/edit/${id}${keep}`} />;
|
|
20
|
+
if (file.startsWith("dispform") && id) return <Navigate replace to={`${s.screen.route}/display/${id}${keep}`} />;
|
|
21
|
+
return <Navigate replace to={s.screen.route} />;
|
|
22
|
+
}
|
|
23
|
+
return <Navigate replace to={s.screen.route} />;
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
return <div className="sp-content"><h2>404 NOT FOUND</h2><p>Sorry, something went wrong. The page {loc.pathname} does not exist.</p></div>;
|
|
27
|
+
}
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
// Routes for one list's forms: AllItems, NewForm, EditForm, DispForm.
|
|
2
|
+
import { Route, Routes, useParams } from "react-router-dom";
|
|
3
|
+
import { Layout } from "../components/sp/Layout";
|
|
4
|
+
import { ListForm } from "../components/sp/Form";
|
|
5
|
+
import { ListView } from "../components/sp/ListView";
|
|
6
|
+
import type { Mode, Spec } from "../runtime/spec";
|
|
7
|
+
|
|
8
|
+
function FormRoute({ spec, mode }: { spec: Spec; mode: Mode }) {
|
|
9
|
+
const { id } = useParams();
|
|
10
|
+
const list = spec.lists[spec.screen.list as string];
|
|
11
|
+
const suffix = mode === "new" ? "New Item" : list.title;
|
|
12
|
+
return (
|
|
13
|
+
<Layout title={mode === "new" ? `${list.title} - ${suffix}` : list.title}>
|
|
14
|
+
<ListForm key={`${mode}-${id ?? ""}`} spec={spec} mode={mode} id={id} />
|
|
15
|
+
</Layout>
|
|
16
|
+
);
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export function ListFormScreen({ spec }: { spec: Spec }) {
|
|
20
|
+
const listName = spec.screen.list as string;
|
|
21
|
+
const list = spec.lists[listName];
|
|
22
|
+
const views = list.views ?? {};
|
|
23
|
+
const v = views.AllItems ?? Object.values(views)[0];
|
|
24
|
+
const view = { fields: v?.fields?.length ? v.fields : list.fields.slice(0, 6).map((f) => f.name), orderby: "ID desc", top: v?.rowLimit ?? 30 };
|
|
25
|
+
return (
|
|
26
|
+
<Routes>
|
|
27
|
+
<Route index element={<Layout title={list.title}><ListView list={listName} fields={list.fields} view={view} route={spec.screen.route} /></Layout>} />
|
|
28
|
+
{spec.screen.modes.includes("new") && <Route path="new" element={<FormRoute spec={spec} mode="new" />} />}
|
|
29
|
+
{spec.screen.modes.includes("edit") && <Route path="edit/:id" element={<FormRoute spec={spec} mode="edit" />} />}
|
|
30
|
+
{spec.screen.modes.includes("display") && <Route path="display/:id" element={<FormRoute spec={spec} mode="display" />} />}
|
|
31
|
+
</Routes>
|
|
32
|
+
);
|
|
33
|
+
}
|