feat(tables): rollup Add Row routes via the party column
The project-level MDL/RSK rollup specs lose `addable: false` and gain
a sibling form schema (default-project-{mdl,rsk}.form.yaml) that
makes `party` a required field. + Add row on the rollup view is now
live: the user types the party name in the Package column, the
server reads `party` from the body, validates that
<project>/archive/<party>/ exists on disk, strips the field, and
writes the row into archive/<party>/<slot>/<date>-<email>.yaml. The
response Location is the synthetic <project>/<slot>/<party>__<file>.yaml
URL so the rollup table client swaps the draft URL cleanly.
Wrong party = 422 with a clear error pointing at the SSR view as the
place to create the folder first. No auto-creation here — the rollup
is for filing deliverables/risks against existing packages, not for
spinning up new ones.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
cef7188a77
commit
f3d334a221
9 changed files with 383 additions and 19 deletions
91
zddc/internal/handler/default-project-mdl.form.yaml
Normal file
91
zddc/internal/handler/default-project-mdl.form.yaml
Normal file
|
|
@ -0,0 +1,91 @@
|
|||
# Default project-rollup MDL row schema, served by zddc-server when
|
||||
# no operator-supplied form.yaml exists at <project>/mdl/.
|
||||
#
|
||||
# Identical to the per-party MDL schema (default-mdl.form.yaml)
|
||||
# except for one extra required field: `party`. That field is the
|
||||
# routing key — the server reads it on POST <project>/mdl/form.html,
|
||||
# finds the matching <project>/archive/<party>/ folder, and writes
|
||||
# the row inside its mdl/ subfolder. The `party` value is stripped
|
||||
# from the YAML on write (folder name IS the identity); on read the
|
||||
# dispatcher injects it back so the rollup table can show the
|
||||
# Package column.
|
||||
#
|
||||
# To customize: drop your own form.yaml at <project>/mdl/form.yaml.
|
||||
# Keep the `party` field shape unless you also customize the rollup
|
||||
# create handler — the server's routing depends on it.
|
||||
|
||||
title: Deliverable (project rollup)
|
||||
description: One deliverable across all parties. The first field (Package) routes the row to the matching archive/<party>/mdl/ folder; the rest mirrors the per-party MDL schema.
|
||||
|
||||
schema:
|
||||
type: object
|
||||
required: [party, originator, project, discipline, type, sequence, title]
|
||||
additionalProperties: false
|
||||
properties:
|
||||
party:
|
||||
type: string
|
||||
title: Package (party folder)
|
||||
description: Routing key — must match an existing <project>/archive/<party>/ folder. Typical naming = MasterFormat 4-digit code + C|P + sequence digit (e.g. 0330C1).
|
||||
pattern: "^[A-Za-z0-9][A-Za-z0-9.-]*$"
|
||||
minLength: 1
|
||||
originator:
|
||||
type: string
|
||||
title: Originator
|
||||
description: Organizational unit responsible for this deliverable (e.g. ACME).
|
||||
minLength: 1
|
||||
phase:
|
||||
type: string
|
||||
title: Phase
|
||||
description: Optional project phase code (e.g. ECI, EPC).
|
||||
project:
|
||||
type: string
|
||||
title: Project
|
||||
description: Project identifier, or your corporate placeholder for non-project deliverables.
|
||||
minLength: 1
|
||||
area:
|
||||
type: string
|
||||
title: Area
|
||||
description: Optional area / budget code (e.g. B02).
|
||||
discipline:
|
||||
type: string
|
||||
title: Discipline
|
||||
description: Engineering or functional group code (EL, ME, CV, PM, ...).
|
||||
minLength: 1
|
||||
type:
|
||||
type: string
|
||||
title: Document type
|
||||
description: Document category code within the discipline (SPC, DWG, RPT, ...).
|
||||
minLength: 1
|
||||
sequence:
|
||||
type: string
|
||||
title: Sequence
|
||||
description: Zero-padded integer (0001, 0042, 2623). Stored as a string so leading zeros survive YAML.
|
||||
minLength: 1
|
||||
suffix:
|
||||
type: string
|
||||
title: Suffix
|
||||
description: Optional structural-part suffix.
|
||||
title:
|
||||
type: string
|
||||
title: Deliverable title
|
||||
minLength: 1
|
||||
plannedRevision:
|
||||
type: string
|
||||
title: Planned revision
|
||||
plannedDate:
|
||||
type: string
|
||||
title: Planned date
|
||||
format: date
|
||||
status:
|
||||
type: string
|
||||
title: Current status
|
||||
enum: [DFT, IFR, IFA, IFC, AFC, AB]
|
||||
owner:
|
||||
type: string
|
||||
title: Owner
|
||||
notes:
|
||||
type: string
|
||||
title: Notes
|
||||
ui:
|
||||
notes:
|
||||
ui:widget: textarea
|
||||
|
|
@ -8,15 +8,14 @@
|
|||
# column is derived from the row's source folder (path-injected by
|
||||
# the server, not stored in the YAML).
|
||||
#
|
||||
# + Add row is suppressed in this view because the party affiliation
|
||||
# would be ambiguous — add deliverables at the per-party path
|
||||
# (<project>/archive/<party>/mdl/) and they'll appear here on next
|
||||
# load.
|
||||
# + Add row IS enabled here: the `party` column doubles as the
|
||||
# routing key — the server reads the submitted `party` field, finds
|
||||
# the matching <project>/archive/<party>/ folder, and writes the row
|
||||
# inside its mdl/ subfolder. The party folder must already exist
|
||||
# (create it via the SSR view).
|
||||
|
||||
title: Project Deliverables (all parties)
|
||||
description: Every deliverable across all parties under archive/. Click a row to edit; add rows at the per-party MDL view.
|
||||
|
||||
addable: false
|
||||
description: Every deliverable across all parties under archive/. Click a row to edit; + Add row uses the Package column to route the new row to the matching archive/<party>/mdl/ folder.
|
||||
|
||||
columns:
|
||||
- field: party
|
||||
|
|
|
|||
82
zddc/internal/handler/default-project-rsk.form.yaml
Normal file
82
zddc/internal/handler/default-project-rsk.form.yaml
Normal file
|
|
@ -0,0 +1,82 @@
|
|||
# Default project-rollup RSK row schema, served by zddc-server when
|
||||
# no operator-supplied form.yaml exists at <project>/rsk/.
|
||||
#
|
||||
# Identical to the per-party RSK schema (default-rsk.form.yaml)
|
||||
# except for one extra required field: `party`. The server reads it
|
||||
# on POST <project>/rsk/form.html and routes the row to the matching
|
||||
# <project>/archive/<party>/rsk/ folder. The `party` value is
|
||||
# stripped from the YAML on write (folder name IS the identity); on
|
||||
# read the dispatcher injects it back.
|
||||
#
|
||||
# To customize: drop your own form.yaml at <project>/rsk/form.yaml.
|
||||
# Keep the `party` field shape unless you also customize the rollup
|
||||
# create handler.
|
||||
|
||||
title: Risk (project rollup)
|
||||
description: One risk across all parties. The first field (Package) routes the row to the matching archive/<party>/rsk/ folder; the rest mirrors the per-party RSK schema.
|
||||
|
||||
schema:
|
||||
type: object
|
||||
required: [party, id, title]
|
||||
additionalProperties: false
|
||||
properties:
|
||||
party:
|
||||
type: string
|
||||
title: Package (party folder)
|
||||
description: Routing key — must match an existing <project>/archive/<party>/ folder.
|
||||
pattern: "^[A-Za-z0-9][A-Za-z0-9.-]*$"
|
||||
minLength: 1
|
||||
id:
|
||||
type: string
|
||||
title: ID
|
||||
description: Stable identifier, e.g. R-001.
|
||||
minLength: 1
|
||||
title:
|
||||
type: string
|
||||
title: Risk
|
||||
minLength: 1
|
||||
category:
|
||||
type: string
|
||||
title: Category
|
||||
description:
|
||||
type: string
|
||||
title: Description
|
||||
likelihood:
|
||||
type: integer
|
||||
title: Likelihood
|
||||
minimum: 1
|
||||
maximum: 5
|
||||
impact:
|
||||
type: integer
|
||||
title: Impact
|
||||
minimum: 1
|
||||
maximum: 5
|
||||
severity:
|
||||
type: integer
|
||||
title: Severity
|
||||
minimum: 1
|
||||
maximum: 25
|
||||
mitigation:
|
||||
type: string
|
||||
title: Mitigation
|
||||
owner:
|
||||
type: string
|
||||
title: Owner
|
||||
status:
|
||||
type: string
|
||||
title: Status
|
||||
enum: [open, mitigated, accepted, closed]
|
||||
dueDate:
|
||||
type: string
|
||||
title: Due date
|
||||
format: date
|
||||
notes:
|
||||
type: string
|
||||
title: Notes
|
||||
ui:
|
||||
description:
|
||||
ui:widget: textarea
|
||||
mitigation:
|
||||
ui:widget: textarea
|
||||
notes:
|
||||
ui:widget: textarea
|
||||
|
|
@ -7,15 +7,14 @@
|
|||
# column is derived from the row's source folder (path-injected by
|
||||
# the server, not stored in the YAML).
|
||||
#
|
||||
# + Add row is suppressed in this view because the party affiliation
|
||||
# would be ambiguous — add risks at the per-party path
|
||||
# (<project>/archive/<party>/rsk/) and they'll appear here on next
|
||||
# load.
|
||||
# + Add row IS enabled here: the `party` column doubles as the
|
||||
# routing key — the server reads the submitted `party` field, finds
|
||||
# the matching <project>/archive/<party>/ folder, and writes the row
|
||||
# inside its rsk/ subfolder. The party folder must already exist
|
||||
# (create it via the SSR view).
|
||||
|
||||
title: Project Risk Register (all parties)
|
||||
description: Every risk across all parties under archive/. Click a row to edit; add rows at the per-party RSK view.
|
||||
|
||||
addable: false
|
||||
description: Every risk across all parties under archive/. Click a row to edit; + Add row uses the Package column to route the new row to the matching archive/<party>/rsk/ folder.
|
||||
|
||||
columns:
|
||||
- field: party
|
||||
|
|
|
|||
|
|
@ -89,9 +89,12 @@ type FormRequest struct {
|
|||
// SubmitURL is the URL the form should POST back to (the server-injected
|
||||
// "submit to my own URL" value).
|
||||
SubmitURL string
|
||||
// Project carries the project name for create-via-ssr requests. Empty
|
||||
// for all other kinds.
|
||||
// Project carries the project name for create-via-ssr /
|
||||
// create-via-rollup requests. Empty for all other kinds.
|
||||
Project string
|
||||
// Slot carries the slot name ("mdl" or "rsk") for create-via-rollup
|
||||
// requests. Empty for all other kinds.
|
||||
Slot string
|
||||
}
|
||||
|
||||
// RecognizeFormRequest classifies r as a form-system request, or returns nil
|
||||
|
|
@ -130,6 +133,26 @@ func RecognizeFormRequest(fsRoot, method, urlPath string) *FormRequest {
|
|||
}
|
||||
}
|
||||
|
||||
// Project-rollup MDL / RSK create: /<project>/(mdl|rsk)/form.html
|
||||
// reads a `party` field from the body and routes the new row to
|
||||
// <project>/archive/<party>/<slot>/. Recognized before the generic
|
||||
// /<dir>/form.html branch so a virtual rollup URL doesn't get
|
||||
// misrouted as an in-dir create.
|
||||
if project, slot, ok := zddc.IsRollupCreateURL(urlPath); ok {
|
||||
kind := "render-empty"
|
||||
if method == http.MethodPost {
|
||||
kind = "create-via-rollup"
|
||||
}
|
||||
specAbs := filepath.Join(fsRoot, project, slot, "form.yaml")
|
||||
return &FormRequest{
|
||||
Kind: kind,
|
||||
SpecPath: specAbs,
|
||||
SubmitURL: urlPath,
|
||||
Project: project,
|
||||
Slot: slot,
|
||||
}
|
||||
}
|
||||
|
||||
underlying := strings.TrimSuffix(urlPath, ".html")
|
||||
|
||||
// specEligible accepts a spec path that exists on disk OR matches
|
||||
|
|
@ -249,6 +272,8 @@ func ServeForm(cfg config.Config, req *FormRequest, w http.ResponseWriter, r *ht
|
|||
serveFormUpdate(cfg, req, w, r)
|
||||
case "create-via-ssr":
|
||||
serveFormCreateSSR(cfg, req, w, r)
|
||||
case "create-via-rollup":
|
||||
serveFormCreateRollup(cfg, req, w, r)
|
||||
default:
|
||||
http.Error(w, "unknown form request kind", http.StatusInternalServerError)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -30,6 +30,7 @@ import (
|
|||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"codeberg.org/VARASYS/ZDDC/zddc/internal/config"
|
||||
"codeberg.org/VARASYS/ZDDC/zddc/internal/jsonschema"
|
||||
|
|
@ -179,6 +180,133 @@ func serveFormCreateSSR(cfg config.Config, req *FormRequest, w http.ResponseWrit
|
|||
auditFile(r, "ssr-create", rowURL, http.StatusCreated, len(yamlBytes), nil)
|
||||
}
|
||||
|
||||
// serveFormCreateRollup adds a row to a project-level MDL or RSK
|
||||
// rollup view by writing it inside the per-party folder named by the
|
||||
// submitted `party` field.
|
||||
//
|
||||
// Wire-form: POST /<project>/(mdl|rsk)/form.html
|
||||
//
|
||||
// Content-Type: application/yaml | application/json
|
||||
// body: { party: "<name>", ...row fields... }
|
||||
//
|
||||
// The rollup form schema (default-project-{mdl,rsk}.form.yaml) makes
|
||||
// `party` a required field; the rollup view's `Package` column maps
|
||||
// to it. The party folder must already exist — create it via the
|
||||
// SSR view first. ACL gate: ActionCreate at
|
||||
// <project>/archive/<party>/<slot>/, same chain the generic
|
||||
// serveFormCreate would gate against if the user were on the
|
||||
// per-party path directly.
|
||||
//
|
||||
// On success: 201 + Location: /<project>/<slot>/<party>__<filename>.yaml,
|
||||
// the virtual row URL that the rollup table client uses to address
|
||||
// the new row.
|
||||
func serveFormCreateRollup(cfg config.Config, req *FormRequest, w http.ResponseWriter, r *http.Request) {
|
||||
email := EmailFromContext(r)
|
||||
if email == "" {
|
||||
http.Error(w, "authentication required", http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
if req.Project == "" || (req.Slot != "mdl" && req.Slot != "rsk") {
|
||||
http.Error(w, "internal: rollup create missing project/slot", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
data, err := decodeRequestData(r)
|
||||
if err != nil {
|
||||
http.Error(w, "request body: "+err.Error(), http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
spec, err := loadFormSpec(cfg.Root, req.SpecPath)
|
||||
if err != nil {
|
||||
http.Error(w, "form spec error: "+err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
if errs := jsonschema.Validate(spec.Schema, data); len(errs) > 0 {
|
||||
writeValidationErrors(w, errs)
|
||||
return
|
||||
}
|
||||
|
||||
dataMap, ok := data.(map[string]interface{})
|
||||
if !ok {
|
||||
http.Error(w, "request body must be a YAML/JSON object", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
partyRaw, _ := dataMap["party"].(string)
|
||||
party := strings.TrimSpace(partyRaw)
|
||||
if !zddc.ValidPartyName(party) {
|
||||
writeValidationErrors(w, []jsonschema.Error{{
|
||||
Path: "/party",
|
||||
Message: "must match " + `^[A-Za-z0-9][A-Za-z0-9.-]*$`,
|
||||
}})
|
||||
return
|
||||
}
|
||||
|
||||
partyAbs := filepath.Join(cfg.Root, req.Project, "archive", party)
|
||||
if !strings.HasPrefix(partyAbs, cfg.Root+string(filepath.Separator)) {
|
||||
http.Error(w, "Not Found", http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
if info, err := os.Stat(partyAbs); err != nil || !info.IsDir() {
|
||||
writeValidationErrors(w, []jsonschema.Error{{
|
||||
Path: "/party",
|
||||
Message: "party folder does not exist — create it via the SSR view first",
|
||||
}})
|
||||
return
|
||||
}
|
||||
|
||||
slotAbs := filepath.Join(partyAbs, req.Slot)
|
||||
slotURL := "/" + req.Project + "/archive/" + party + "/" + req.Slot + "/"
|
||||
rowDirURL := slotURL // The slot folder where the new row lands.
|
||||
_ = rowDirURL // kept for clarity; ACL chain is gated below.
|
||||
|
||||
// ACL gate: create at <project>/archive/<party>/<slot>/. authorizeAction
|
||||
// walks up to the closest existing ancestor (typically <party>/), where
|
||||
// the auto-own .zddc grants the party owner rwcd.
|
||||
if !authorizeAction(cfg, w, r, slotAbs, slotURL, policy.ActionCreate) {
|
||||
return
|
||||
}
|
||||
|
||||
// Strip the routing key from the data before write — the folder
|
||||
// name IS the identity and the per-party MDL/RSK schemas forbid
|
||||
// `additionalProperties` other than the listed ones.
|
||||
delete(dataMap, "party")
|
||||
|
||||
if err := os.MkdirAll(slotAbs, 0o755); err != nil {
|
||||
auditFile(r, "rollup-create", req.SubmitURL, http.StatusInternalServerError, 0, err)
|
||||
http.Error(w, "Internal Server Error", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
dateStr := time.Now().UTC().Format("2006-01-02")
|
||||
emailSan := sanitizeEmail(email)
|
||||
base := dateStr + "-" + emailSan
|
||||
target, fname, ok := pickAvailableFilename(slotAbs, base, ".yaml")
|
||||
if !ok {
|
||||
http.Error(w, "could not pick a free filename (>100 collisions)", http.StatusConflict)
|
||||
return
|
||||
}
|
||||
|
||||
yamlBytes, err := yaml.Marshal(dataMap)
|
||||
if err != nil {
|
||||
auditFile(r, "rollup-create", req.SubmitURL, http.StatusInternalServerError, 0, err)
|
||||
http.Error(w, "marshal yaml: "+err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
if err := zddc.WriteAtomic(target, yamlBytes); err != nil {
|
||||
auditFile(r, "rollup-create", req.SubmitURL, http.StatusInternalServerError, len(yamlBytes), err)
|
||||
http.Error(w, "write: "+err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
rowURL := "/" + req.Project + "/" + req.Slot + "/" + party + "__" + fname
|
||||
w.Header().Set("Location", rowURL)
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.Header().Set("X-ZDDC-Source", "rollup-create")
|
||||
w.WriteHeader(http.StatusCreated)
|
||||
_, _ = w.Write([]byte(fmt.Sprintf(`{"ok":true,"location":%q}`, rowURL)))
|
||||
auditFile(r, "rollup-create", rowURL, http.StatusCreated, len(yamlBytes), nil)
|
||||
}
|
||||
|
||||
// serveSSRRename renames a party folder by rewriting an SSR row URL.
|
||||
//
|
||||
// Wire-form: POST /<project>/ssr/<old>.yaml
|
||||
|
|
|
|||
|
|
@ -65,6 +65,12 @@ var embeddedDefaultProjectMdlTable []byte
|
|||
//go:embed default-project-rsk.table.yaml
|
||||
var embeddedDefaultProjectRskTable []byte
|
||||
|
||||
//go:embed default-project-mdl.form.yaml
|
||||
var embeddedDefaultProjectMdlForm []byte
|
||||
|
||||
//go:embed default-project-rsk.form.yaml
|
||||
var embeddedDefaultProjectRskForm []byte
|
||||
|
||||
// DefaultMdlTableYAML returns the embedded default mdl.table.yaml bytes.
|
||||
// Used by callers that need the canonical spec without going through
|
||||
// the URL-recognition path.
|
||||
|
|
@ -93,6 +99,16 @@ func DefaultProjectMdlTableYAML() []byte { return embeddedDefaultProjectMdlTable
|
|||
// rsk.table.yaml bytes.
|
||||
func DefaultProjectRskTableYAML() []byte { return embeddedDefaultProjectRskTable }
|
||||
|
||||
// DefaultProjectMdlFormYAML returns the embedded project-rollup
|
||||
// mdl.form.yaml bytes. Differs from the per-party MDL form by an
|
||||
// additional required `party` field — the routing key for the
|
||||
// rollup create handler.
|
||||
func DefaultProjectMdlFormYAML() []byte { return embeddedDefaultProjectMdlForm }
|
||||
|
||||
// DefaultProjectRskFormYAML returns the embedded project-rollup
|
||||
// rsk.form.yaml bytes.
|
||||
func DefaultProjectRskFormYAML() []byte { return embeddedDefaultProjectRskForm }
|
||||
|
||||
// IsDefaultSpec reports whether urlPath is one of the embedded
|
||||
// default-spec virtual files served when no operator file exists on
|
||||
// disk. Recognized URL shapes:
|
||||
|
|
@ -195,14 +211,14 @@ func classifyDefaultSpec(rel string) []byte {
|
|||
case "table.yaml":
|
||||
return embeddedDefaultProjectMdlTable
|
||||
case "form.yaml":
|
||||
return embeddedDefaultMdlForm
|
||||
return embeddedDefaultProjectMdlForm
|
||||
}
|
||||
case "rsk":
|
||||
switch file {
|
||||
case "table.yaml":
|
||||
return embeddedDefaultProjectRskTable
|
||||
case "form.yaml":
|
||||
return embeddedDefaultRskForm
|
||||
return embeddedDefaultProjectRskForm
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1511,7 +1511,7 @@ body.is-elevated::after {
|
|||
</svg>
|
||||
<div class="header-title-group">
|
||||
<span class="app-header__title" id="table-title">ZDDC Table</span>
|
||||
<span class="build-timestamp"><span style="color:red;font-weight:bold">v0.0.17-alpha · 2026-05-19 12:37:53 · 847e082-dirty</span></span>
|
||||
<span class="build-timestamp"><span style="color:red;font-weight:bold">v0.0.17-alpha · 2026-05-19 13:13:20 · cef7188-dirty</span></span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="header-right">
|
||||
|
|
|
|||
|
|
@ -253,6 +253,30 @@ func IsSSRCreateURL(urlPath string) (string, bool) {
|
|||
return project, true
|
||||
}
|
||||
|
||||
// IsRollupCreateURL reports whether urlPath is
|
||||
// /<project>/(mdl|rsk)/form.html — the "+ Add row" target on a
|
||||
// project-level MDL or RSK rollup view. Returns the project name +
|
||||
// slot ("mdl" or "rsk") when matched. The rollup-create handler
|
||||
// reads a `party` field from the body and routes the new row into
|
||||
// <project>/archive/<party>/<slot>/.
|
||||
func IsRollupCreateURL(urlPath string) (project, slot string, ok bool) {
|
||||
if urlPath == "" || urlPath[0] != '/' {
|
||||
return "", "", false
|
||||
}
|
||||
parts := strings.Split(strings.TrimPrefix(urlPath, "/"), "/")
|
||||
if len(parts) != 3 || parts[2] != "form.html" {
|
||||
return "", "", false
|
||||
}
|
||||
if parts[1] != "mdl" && parts[1] != "rsk" {
|
||||
return "", "", false
|
||||
}
|
||||
project = parts[0]
|
||||
if project == "" || strings.HasPrefix(project, ".") || strings.HasPrefix(project, "_") {
|
||||
return "", "", false
|
||||
}
|
||||
return project, parts[1], true
|
||||
}
|
||||
|
||||
// StripYAMLHTML returns urlPath with a trailing ".html" stripped iff
|
||||
// the URL has the form-edit shape ".../<name>.yaml.html". Otherwise
|
||||
// returns urlPath unchanged + false. The form recognizer calls this
|
||||
|
|
|
|||
Loading…
Reference in a new issue