Skip to content

Commit 6e7eee6

Browse files
committed
1 parent d8b9ab3 commit 6e7eee6

38 files changed

+1418
-121
lines changed
Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,11 @@
11
import express from 'express';
22
import picklist from './routes/picklist.js';
3+
import form from './routes/form.js';
34
import field from './routes/field.js';
45

56
const router = express.Router();
67
router.use('/field', field);
8+
router.use('/form', form);
79
router.use('/picklist', picklist);
810

911
export default router;
Lines changed: 83 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,83 @@
1+
import { Router, json } from 'express';
2+
import handleError from '../utils/handleError.js';
3+
import { validate, schema } from '../utils/validation/index.js';
4+
import models from '#models';
5+
import logger from '#lib/logger.js';
6+
7+
const router = Router();
8+
9+
router.get('/', validate(schema.formQuery, {reqParts: ['query']}), async (req, res) => {
10+
try {
11+
logger.info('Form query validated', req.context.logSignal);
12+
const r = await models.form.query(req.payload);
13+
if (r.error) {
14+
throw r.error;
15+
}
16+
logger.info('Form query successful', req.context.logSignal, {resultCount: r.res.total_count});
17+
res.status(200).json(r.res);
18+
} catch (e) {
19+
return handleError(res, req, e);
20+
}
21+
});
22+
23+
router.get('/:idOrName', async (req, res) => {
24+
try {
25+
const r = await models.form.get(req.params.idOrName, { errorOnMissing: true });
26+
if (r.error) {
27+
throw r.error;
28+
}
29+
logger.info('Form get successful', req.context.logSignal, { formId: r.res.form_id});
30+
if ( !req.query.include_items ) {
31+
delete r.res.items;
32+
}
33+
res.status(200).json(r.res);
34+
} catch (e) {
35+
return handleError(res, req, e);
36+
}
37+
});
38+
39+
// create form
40+
router.post('/', json(), validate(schema.formCreate, {reqParts: ['body']}), async (req, res) => {
41+
try {
42+
logger.info('Form validated', req.context.logSignal);
43+
const r = await models.form.create(req.payload);
44+
if (r.error) {
45+
throw r.error;
46+
}
47+
logger.info('Form created', req.context.logSignal, { form: r.res});
48+
res.status(200).json(r.res);
49+
} catch (e) {
50+
return handleError(res, req, e);
51+
}
52+
});
53+
54+
router.patch('/', json(), validate(schema.formUpdate, {reqParts: ['body']}), async (req, res) => {
55+
try {
56+
logger.info('Form update validated', req.context.logSignal, {formId: req.payload.form_id});
57+
const r = await models.form.patch(req.payload.form_id, req.payload);
58+
if (r.error) {
59+
throw r.error;
60+
}
61+
logger.info('Form update successful', req.context.logSignal, {form: r.res});
62+
res.status(200).json(r.res);
63+
} catch (e) {
64+
return handleError(res, req, e);
65+
}
66+
});
67+
68+
router.delete('/:idOrName', validate(schema.formIdOrNameSchema, {reqParts: ['params']}), async (req, res) => {
69+
try {
70+
logger.info('Form delete validated', req.context.logSignal, {formIdOrName: req.params.idOrName});
71+
const r = await models.form.delete(req.params.idOrName);
72+
if (r.error) {
73+
throw r.error;
74+
}
75+
logger.info('Form delete successful', req.context.logSignal, {form: r.res});
76+
res.status(200).json(r.res);
77+
} catch (e) {
78+
return handleError(res, req, e);
79+
}
80+
});
81+
82+
83+
export default router;

services/client/controllers/api/utils/validation/index.js

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,13 @@ import {
1313
fieldIdOrNameSchema
1414
} from './schemas/field.js';
1515

16+
import {
17+
formCreateSchema,
18+
formQuerySchema,
19+
formUpdateSchema,
20+
formIdOrNameSchema
21+
} from './schemas/form.js';
22+
1623
/**
1724
* @description Middleware to validate request data against a Zod schema.
1825
* Combines req.params, req.query, and req.body for validation.
@@ -65,7 +72,11 @@ const schema = {
6572
fieldCreate: fieldCreateSchema,
6673
fieldQuery: fieldQuerySchema,
6774
fieldUpdate: fieldUpdateSchema,
68-
fieldIdOrNameSchema: fieldIdOrNameSchema
75+
fieldIdOrNameSchema: fieldIdOrNameSchema,
76+
formCreate: formCreateSchema,
77+
formQuery: formQuerySchema,
78+
formUpdate: formUpdateSchema,
79+
formIdOrNameSchema: formIdOrNameSchema
6980
};
7081

7182
export { validate, schema };
Lines changed: 93 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,93 @@
1+
import * as z from "zod";
2+
import { requiredString, urlFriendlyString, pageParam, perPageParam, booleanParam, toString } from "./utils.js";
3+
import models from '#models';
4+
5+
const srNameUnique = async (data, ctx) => {
6+
if ( !data.name ) return;
7+
const existing = await models.form.get(data.name);
8+
if (existing.error) {
9+
throw existing.error;
10+
}
11+
if ( !existing.res ) {
12+
return;
13+
}
14+
if ( !data.form_id || existing.res.form_id !== data.form_id ) {
15+
ctx.addIssue({
16+
code: z.ZodIssueCode.custom,
17+
message: 'A form with this name already exists',
18+
path: ['name']
19+
});
20+
}
21+
}
22+
23+
const srValidateFormId = async (data, ctx) => {
24+
if ( data.form_id ) {
25+
const existing = await models.form.get(data.form_id);
26+
if (existing.error) {
27+
throw existing.error;
28+
}
29+
if (!existing.res) {
30+
ctx.addIssue({
31+
code: z.ZodIssueCode.custom,
32+
message: 'Form not found',
33+
path: ['form_id']
34+
});
35+
}
36+
} else {
37+
ctx.addIssue({
38+
code: z.ZodIssueCode.custom,
39+
message: 'form_id is required',
40+
path: ['form_id']
41+
});
42+
}
43+
}
44+
45+
const formBaseSchema = z.object({
46+
description: toString.pipe(z.string().max(300)).optional(),
47+
label: requiredString().pipe(z.string().max(250)),
48+
is_archived: z.boolean().optional()
49+
});
50+
51+
const formCreateSchema = formBaseSchema.extend({
52+
name: requiredString().pipe(urlFriendlyString.max(250))
53+
})
54+
.superRefine(srNameUnique);
55+
56+
const formUpdateSchema = formBaseSchema.partial().extend({
57+
form_id: z.string().uuid()
58+
})
59+
.superRefine(srValidateFormId);
60+
61+
const formIdOrNameSchema = z.object({
62+
idOrName: requiredString()
63+
.superRefine(async (idOrName, ctx) => {
64+
const existing = await models.form.get(idOrName);
65+
66+
if (existing.error) {
67+
throw existing.error;
68+
}
69+
70+
if (!existing.res) {
71+
ctx.addIssue({
72+
code: z.ZodIssueCode.custom,
73+
message: 'Form not found',
74+
path: []
75+
});
76+
}
77+
return true;
78+
})
79+
});
80+
81+
82+
const formQuerySchema = z.object({
83+
page: pageParam,
84+
per_page: perPageParam(15),
85+
q: z.string().max(250).optional()
86+
});
87+
88+
export {
89+
formCreateSchema,
90+
formQuerySchema,
91+
formUpdateSchema,
92+
formIdOrNameSchema
93+
}

services/client/controllers/api/utils/validation/schemas/utils.js

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -56,7 +56,7 @@ export const pageParam = z.preprocess(
5656
z.number().int().positive("Page must be a positive integer")
5757
);
5858

59-
export const perPageParam = (defaultValue = 15, maxValue = 50) =>
59+
export const perPageParam = (defaultValue = 15, maxValue = 100) =>
6060
z.preprocess(
6161
v => {
6262
if (v == null || v === '') return defaultValue;

services/client/controllers/icons.js

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@ export default preload([
88
'plug-circle-exclamation', 'xmark', 'trash',
99
'spinner', 'circle-exclamation', 'upload', 'circle-info', 'plus',
1010
'check', 'circle-chevron-right', 'ellipsis', 'arrow-up', 'arrow-down',
11-
'database'
11+
'database', 'eye-slash'
1212
]
1313
},
1414
{

services/client/controllers/static.js

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,7 @@ export default (app) => {
1414
let assetsDir = path.join(__dirname, '../public');
1515
logger.info(`Serving static assets from ${assetsDir}`);
1616

17-
const routes = ['picklist', 'field'];
17+
const routes = ['picklist', 'field', 'form-admin'];
1818
const appTitle = 'Reference Statistics';
1919

2020
spaMiddleware({
Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,6 @@
11
import '../elements/pages/admin/ref-stats-page-picklist.js';
22
import '../elements/pages/admin/ref-stats-page-picklist-single.js';
33
import '../elements/pages/admin/ref-stats-page-field.js';
4-
import '../elements/pages/admin/ref-stats-page-field-single.js';
4+
import '../elements/pages/admin/ref-stats-page-field-single.js';
5+
import '../elements/pages/admin/ref-stats-page-form-admin.js';
6+
import '../elements/pages/admin/ref-stats-page-form-admin-single.js';

services/client/dev/css/index.js

Lines changed: 6 additions & 38 deletions
Original file line numberDiff line numberDiff line change
@@ -3,9 +3,12 @@ import sharedStyles from '@ucd-lib/theme-sass/style-ucdlib.css';
33
import brandCssProps from '@ucd-lib/theme-sass/css-properties.css';
44
import fonts from './fonts.css';
55
import headings from './headings.css';
6+
import linkList from './link-list.css';
7+
import typeahead from './typeahead.css';
68

79
import { styles as corkFieldContainerStyles } from '#components/cork-field-container.tpl.js';
810
import { styles as picklistTypeaheadStyles } from '#components/ref-stats-picklist-typeahead.tpl.js';
11+
import { styles as formTypeaheadStyles } from '#components/ref-stats-form-typeahead.tpl.js';
912

1013
function getLitStyles(styles){
1114
return styles().map(s => s.cssText).join('\n');
@@ -16,6 +19,8 @@ const styles = `
1619
${brandCssProps}
1720
${fonts}
1821
${headings}
22+
${linkList}
23+
${typeahead}
1924
[hidden] {
2025
display: none !important;
2126
}
@@ -53,44 +58,6 @@ const styles = `
5358
textarea[disabled] {
5459
background-color: #f0f0f0;
5560
}
56-
.ucd-link-list-item {
57-
display: flex;
58-
gap: .25rem;
59-
}
60-
.ucd-link-list-item + .ucd-link-list-item {
61-
margin-top: 1rem;
62-
}
63-
.ucd-link-list-item .ucd-link-list-item--title {
64-
display: inline-block;
65-
color: var(--ucd-blue-80, #13639E);
66-
text-decoration: none;
67-
font-weight: 700;
68-
font-size: 1rem;
69-
line-height: 1.6rem;
70-
}
71-
.ucd-link-list-item .ucd-link-list-item--title:hover {
72-
text-decoration: underline;
73-
}
74-
.ucd-link-list-item .ucd-link-list-item--icon {
75-
color: var(--category-brand, #73abdd);
76-
--cork-icon-size: 1rem;
77-
margin-top: 0.3rem;
78-
}
79-
.ucd-link-list-item .ucd-link-list-item--excerpt {
80-
display: block;
81-
color: var(--ucd-black-70, #4C4C4C);
82-
font-size: .875rem;
83-
line-height: 1.3rem;
84-
font-weight: 400;
85-
}
86-
.ucd-link-list-item .ucd-link-list-item--badge {
87-
display: inline-block;
88-
background-color: var(--ucd-blue-50, #cce0f3);
89-
color: var(--ucd-blue, #022851);
90-
padding: 0 0.25rem;
91-
font-size: .875rem;
92-
font-weight: 700;
93-
}
9461
button.link-button {
9562
all: unset;
9663
color: var(--ucd-blue-80, #13639E);
@@ -111,6 +78,7 @@ const styles = `
11178
}
11279
${getLitStyles(corkFieldContainerStyles)}
11380
${getLitStyles(picklistTypeaheadStyles)}
81+
${getLitStyles(formTypeaheadStyles)}
11482
`;
11583

11684
let sharedStyleElement = document.createElement('style');
Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,38 @@
1+
.ucd-link-list-item {
2+
display: flex;
3+
gap: .25rem;
4+
}
5+
.ucd-link-list-item + .ucd-link-list-item {
6+
margin-top: 1rem;
7+
}
8+
.ucd-link-list-item--title {
9+
display: inline-block;
10+
color: var(--ucd-blue-80, #13639E);
11+
text-decoration: none;
12+
font-weight: 700;
13+
font-size: 1rem;
14+
line-height: 1.6rem;
15+
}
16+
.ucd-link-list-item--title:hover {
17+
text-decoration: underline;
18+
}
19+
.ucd-link-list-item .ucd-link-list-item--icon {
20+
color: var(--category-brand, #73abdd);
21+
--cork-icon-size: 1rem;
22+
margin-top: 0.3rem;
23+
}
24+
.ucd-link-list-item--excerpt {
25+
display: block;
26+
color: var(--ucd-black-70, #4C4C4C);
27+
font-size: .875rem;
28+
line-height: 1.3rem;
29+
font-weight: 400;
30+
}
31+
.ucd-link-list-item--badge {
32+
display: inline-block;
33+
background-color: var(--ucd-blue-50, #cce0f3);
34+
color: var(--ucd-blue, #022851);
35+
padding: 0 0.25rem;
36+
font-size: .875rem;
37+
font-weight: 700;
38+
}

0 commit comments

Comments
 (0)