Page Menu
Home
Sealhub
Search
Configure Global Search
Log In
Files
F3010470
stateful-page.ts
No One
Temporary
Actions
Download File
Edit File
Delete File
View Transforms
Subscribe
Mute Notifications
Award Token
Flag For Later
Size
6 KB
Referenced Files
None
Subscribers
None
stateful-page.ts
View Options
import
Router
from
"@koa/router"
;
import
{
predicates
,
hasShape
,
is
,
hasField
}
from
"@sealcode/ts-predicates"
;
import
deepmerge
,
{
ArrayMergeOptions
}
from
"deepmerge"
;
import
{
BaseContext
}
from
"koa"
;
import
{
Templatable
,
tempstream
}
from
"tempstream"
;
import
{
from_base64
,
to_base64
}
from
"../utils/base64.js"
;
import
{
ExtractTail
}
from
"../utils/utils.js"
;
import
{
Mountable
}
from
"./mountable.js"
;
export
type
StatefulPageActionDescription
<
ActionName
>
=
|
{
action
:
ActionName
;
label
?:
string
;
content
?:
string
;
}
|
ActionName
;
export
type
StateAndMetadata
<
State
,
Actions
>
=
|
{
state
:
State
;
inputs
:
Record
<
string
,
string
>
;
action
:
keyof
Actions
;
action_args
:
string
;
}
|
{
state
:
State
;
inputs
:
Record
<
string
,
string
>
;
action
:
null
;
action_args
:
null
;
$
:
Record
<
string
,
unknown
>
;
};
export
abstract
class
StatefulPage
<
State
extends
Record
<
string
,
unknown
>
,
Actions
extends
Record
<
string
,
(
state
:
State
,
inputs
:
Record
<
string
,
string
>
,
...
args
:
unknown
[]
)
=>
State
|
Promise
<
State
>
>
>
extends
Mountable
{
abstract
actions
:
Actions
;
abstract
getInitialState
()
:
State
|
Promise
<
State
>
;
abstract
render
(
ctx
:
BaseContext
,
state
:
State
,
inputs
:
Record
<
string
,
string
>
)
:
Templatable
|
Promise
<
Templatable
>
;
async
canAccess
()
{
return
<
const
>
{
canAccess
:
true
,
message
:
""
};
}
constructor
()
{
super
();
const
original_render
=
this
.
render
.
bind
(
this
);
this
.
render
=
async
(
ctx
:
BaseContext
,
state
:
State
,
inputs
:
Record
<
string
,
string
>
)
=>
{
return
this
.
wrapInLayout
(
ctx
,
this
.
wrapInForm
(
state
,
await
original_render
(
ctx
,
state
,
inputs
)
),
state
);
};
}
abstract
wrapInLayout
(
ctx
:
BaseContext
,
content
:
Templatable
,
state
:
State
)
:
Templatable
;
wrapInForm
(
state
:
State
,
content
:
Templatable
)
:
Templatable
{
return
tempstream
/* HTML */
`<form action="./" method="POST">
<input
name="state"
type="hidden"
value="
${
to_base64
(
JSON
.
stringify
(
state
))
}
"
/>
${
content
}
</form>`
;
}
extractActionAndLabel
<
ActionName
extends
keyof
Actions
>
(
action_description
:
StatefulPageActionDescription
<
ActionName
>
)
:
{
action
:
string
;
label
:
string
;
content
:
string
}
{
let
label
,
action
,
content
:
string
;
if
(
is
(
action_description
,
predicates
.
object
))
{
action
=
action_description
.
action
.
toString
();
label
=
action_description
.
label
||
action
;
content
=
action_description
.
content
||
label
;
}
else
{
action
=
action_description
.
toString
();
label
=
action
;
content
=
label
;
}
return
{
action
,
label
,
content
};
}
makeActionURL
<
ActionName
extends
keyof
Actions
>
(
action_description
:
StatefulPageActionDescription
<
ActionName
>
,
...
args
:
ExtractTail
<
ExtractTail
<
Parameters
<
Actions
[
ActionName
]
>>>
)
{
const
{
action
}
=
this
.
extractActionAndLabel
(
action_description
);
return
`./?action=
${
action
}
&action_args=
${
to_base64
(
JSON
.
stringify
(
args
)
)
}
`
;
}
makeActionButton
<
ActionName
extends
keyof
Actions
>
(
_state
:
State
,
action_description
:
StatefulPageActionDescription
<
ActionName
>
,
...
args
:
ExtractTail
<
ExtractTail
<
Parameters
<
Actions
[
ActionName
]
>>>
)
{
let
{
label
,
content
}
=
this
.
extractActionAndLabel
(
action_description
);
return
/* HTML */
`
<button
type="submit"
formaction="
${
this
.
makeActionURL
(
action_description
,
...
args
)
}
"
title="
${
label
}
"
>
${
content
}
</button>
`
;
}
makeActionCallback
<
ActionName
extends
keyof
Actions
>
(
action_description
:
|
{
action
:
ActionName
;
label
?:
string
;
}
|
ActionName
,
...
args
:
ExtractTail
<
ExtractTail
<
Parameters
<
Actions
[
ActionName
]
>>>
)
{
return
`(()=>{const form = this.closest('form'); form.action='
${
this
.
makeActionURL
(
action_description
,
...
args
)
}
'; form.requestSubmit()})()`
;
}
rerender
()
{
return
"this.closest('form').requestSubmit()"
;
}
async
preprocessState
(
values
:
State
)
:
Promise
<
State
>
{
return
values
;
}
async
preprocessOverrides
(
state
:
State
,
values
:
Record
<
string
,
unknown
>
)
:
Promise
<
Record
<
string
,
unknown
>>
{
return
values
;
}
async
extractState
(
ctx
:
BaseContext
)
:
Promise
<
StateAndMetadata
<
State
,
Actions
>>
{
if
(
!
hasShape
(
{
action
:
predicates
.
maybe
(
predicates
.
string
),
state
:
predicates
.
string
,
action_args
:
predicates
.
maybe
(
predicates
.
string
),
$
:
predicates
.
maybe
(
predicates
.
object
),
},
ctx
.
$body
)
)
{
console
.
error
(
"Wrong data: "
,
ctx
.
$body
);
throw
new
Error
(
"wrong formdata shape"
);
}
const
inputs
=
Object
.
fromEntries
(
Object
.
entries
(
ctx
.
$body
).
filter
(
([
key
])
=>
!
[
"action"
,
"state"
,
"args"
,
"$"
].
includes
(
key
)
)
)
as
Record
<
string
,
string
>
;
// the "$" key is parsed as dot notation and overrides the state
const
original_state_string
=
ctx
.
$body
.
state
;
const
original_state
=
JSON
.
parse
(
typeof
original_state_string
==
"string"
?
from_base64
(
original_state_string
)
:
"{}"
);
const
$body
=
ctx
.
$body
;
let
state_overrides
=
$body
.
$
||
{};
state_overrides
=
await
this
.
preprocessOverrides
(
original_state
,
state_overrides
);
let
modified_state
=
deepmerge
(
original_state
,
state_overrides
,
{
arrayMerge
:
(
target
:
any
[],
source
:
any
[],
options
:
ArrayMergeOptions
)
=>
{
// https://github.com/TehShrike/deepmerge?tab=readme-ov-file#arraymerge-example-combine-arrays
const
destination
=
target
.
slice
();
source
.
forEach
((
item
,
index
)
=>
{
if
(
typeof
destination
[
index
]
===
"undefined"
)
{
destination
[
index
]
=
options
.
cloneUnlessOtherwiseSpecified
(
item
,
options
);
}
else
if
(
options
.
isMergeableObject
(
item
))
{
destination
[
index
]
=
deepmerge
(
target
[
index
],
item
,
options
);
}
else
if
(
target
.
indexOf
(
item
)
===
-
1
)
{
destination
[
index
]
=
item
;
}
});
return
destination
;
},
})
as
State
;
// giving extending classes a change to modify the state before furhter processing
modified_state
=
await
this
.
preprocessState
(
modified_state
);
if
(
ctx
.
$body
.
action
&&
ctx
.
$body
.
action_args
)
{
return
{
state
:
modified_state
,
action
:
ctx
.
$body
.
action
as
string
,
inputs
,
action_args
:
ctx
.
$body
.
action_args
,
};
}
else
{
return
{
state
:
modified_state
,
action
:
null
,
inputs
,
action_args
:
null
,
$
:
ctx
.
$body
.
$
||
{},
};
}
}
mount
(
router
:
Router
,
path
:
string
)
{
router
.
get
(
path
,
async
(
ctx
)
=>
{
ctx
.
body
=
this
.
render
(
ctx
,
await
this
.
getInitialState
(),
{});
});
router
.
post
(
path
,
async
(
ctx
)
=>
{
const
{
action
,
state
,
inputs
,
action_args
}
=
await
this
.
extractState
(
ctx
);
if
(
action
)
{
ctx
.
body
=
this
.
render
(
ctx
,
await
this
.
actions
[
action
](
state
,
inputs
,
...(
JSON
.
parse
(
from_base64
(
action_args
as
string
)
)
as
unknown
[])
),
inputs
);
}
else
{
ctx
.
body
=
this
.
render
(
ctx
,
state
,
inputs
);
}
ctx
.
status
=
433
;
});
}
}
File Metadata
Details
Attached
Mime Type
text/x-java
Expires
Wed, May 7, 19:43 (1 d, 6 h)
Storage Engine
blob
Storage Format
Raw Data
Storage Handle
642649
Default Alt Text
stateful-page.ts (6 KB)
Attached To
Mode
rSGEN sealgen
Attached
Detach File
Event Timeline
Log In to Comment