-
Notifications
You must be signed in to change notification settings - Fork 490
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Add 'unique' to model schema - new feature that checks unique constraints when saving/publishing #135
base: master
Are you sure you want to change the base?
Add 'unique' to model schema - new feature that checks unique constraints when saving/publishing #135
Changes from all commits
5f214b9
cace0dc
917e7a5
dfedaf0
65832f4
bf66115
df9b8de
0962c4d
921b0cc
ac695f5
2269c96
bd62b65
5e277df
b1e201c
183fe75
c61e32f
ca55b90
263849c
5ae63b3
add000b
a5c2d7c
5105369
8c7af54
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
Original file line number | Diff line number | Diff line change |
---|---|---|
|
@@ -119,7 +119,8 @@ export function renderContentEntryFormFields( | |
table: ApiCmsTable, | ||
database: ApiCmsDatabase, | ||
locales: string[], | ||
disabled: boolean | ||
disabled: boolean, | ||
notValidUniqueFields: string[] | ||
) { | ||
return ( | ||
<> | ||
|
@@ -145,6 +146,7 @@ export function renderContentEntryFormFields( | |
formItemProps: deriveFormItemPropsFromField(field), | ||
typeName: field.type, | ||
required: field.required, | ||
uniqueNotValid: notValidUniqueFields.includes(field.identifier), | ||
...(isCmsTextLike(field) | ||
? { | ||
maxChars: field.maxChars, | ||
|
@@ -187,6 +189,16 @@ function CmsEntryDetailsForm_( | |
const mutateRow_ = useMutateRow(); | ||
const mutateTableRows = useMutateTableRows(); | ||
|
||
const [uniqueFieldsIdentifier, setUniqueFieldsIdentifier] = React.useState< | ||
string[] | ||
>([]); | ||
const [uniqueChangedFields, setUniqueChangedFields] = React.useState< | ||
Dict<unknown> | ||
>({}); | ||
const [notValidUniqueFields, setNotVaildUniqueFields] = React.useState< | ||
string[] | ||
>([]); | ||
|
||
const mutateRow = async () => { | ||
const newRow = await mutateRow_(table.id, row.id); | ||
if (newRow) { | ||
|
@@ -199,7 +211,9 @@ function CmsEntryDetailsForm_( | |
const [form] = useForm(); | ||
|
||
const hasFormError = React.useCallback(() => { | ||
return form.getFieldsError().some((f) => f.errors.length > 0); | ||
return form.getFieldsError().some((f) => { | ||
return f.errors.length > 0; | ||
}); | ||
}, [form]); | ||
|
||
const dataEquals = ( | ||
|
@@ -259,6 +273,10 @@ function CmsEntryDetailsForm_( | |
await validateFields(); | ||
}; | ||
|
||
const isUniqueFieldChanged = () => { | ||
return Object.keys(uniqueChangedFields).length > 0; | ||
}; | ||
|
||
const warnConflict = () => { | ||
notification.error({ | ||
message: "Update conflict detected", | ||
|
@@ -293,10 +311,32 @@ function CmsEntryDetailsForm_( | |
setInConflict(true); | ||
}; | ||
|
||
async function checkUniqueness() { | ||
try { | ||
const opts = { uniqueChangedFields: uniqueChangedFields }; | ||
const checkedNotValid = await api.checkUnique(row.id, opts); | ||
const checkedValid = Object.keys(uniqueChangedFields).filter( | ||
(identifier) => !checkedNotValid.includes(identifier) | ||
); | ||
const checkedValidRemoved = notValidUniqueFields.filter( | ||
(notValid) => !checkedValid.includes(notValid) | ||
); | ||
setNotVaildUniqueFields([ | ||
...new Set([...checkedValidRemoved, ...checkedNotValid]), | ||
]); | ||
setUniqueChangedFields({}); | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
|
||
} catch (err) { | ||
console.log(err); | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Remove try/catch before merging |
||
} | ||
} | ||
|
||
async function performSave() { | ||
const { identifier, ...draftData } = form.getFieldsValue(); | ||
try { | ||
setSaving(true); | ||
if (isUniqueFieldChanged()) { | ||
await checkUniqueness(); | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I don't think checkUniqueness should be in performSave. Rather, you should implement checkUniqueness as a parallel process. |
||
} | ||
await api.updateCmsRow(row.id, { | ||
identifier, | ||
draftData, | ||
|
@@ -319,7 +359,6 @@ function CmsEntryDetailsForm_( | |
await form.validateFields(); | ||
return true; | ||
} catch (err) { | ||
console.error("Validation failed:", err); | ||
return !(err.errorFields?.length > 0); | ||
} | ||
}; | ||
|
@@ -352,6 +391,14 @@ function CmsEntryDetailsForm_( | |
spawn(validateFields()); | ||
}, [row, validateFields]); | ||
|
||
React.useEffect(() => { | ||
setUniqueFieldsIdentifier( | ||
table.schema.fields | ||
.filter((field) => field.unique) | ||
.map((field) => field.identifier) | ||
); | ||
}, []); | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
OR
|
||
|
||
useBeforeUnload(() => { | ||
return hasChanges(); | ||
}, "You have unsaved changes, are you sure?"); | ||
|
@@ -405,11 +452,18 @@ function CmsEntryDetailsForm_( | |
}} | ||
labelCol={{ span: 8 }} | ||
wrapperCol={{ span: 16 }} | ||
onValuesChange={(changedValues, allValues) => { | ||
onValuesChange={(changedValues: Dict<Dict<unknown>>, allValues) => { | ||
if (Object.keys(changedValues).length > 0) { | ||
setHasUnsavedChanges(hasChanges()); | ||
setHasUnpublishedChanges(hasPublishableChanges()); | ||
console.log({ changedFields: changedValues, allFields: allValues }); | ||
const changedField = Object.values(changedValues)[0]; | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Can't assume there is only 1 element in the changed values |
||
if (uniqueFieldsIdentifier.includes(Object.keys(changedField)[0])) { | ||
setUniqueChangedFields({ | ||
...uniqueChangedFields, | ||
...changedField, | ||
}); | ||
} | ||
} | ||
}} | ||
className={"max-scrollable fill-width"} | ||
|
@@ -476,35 +530,43 @@ function CmsEntryDetailsForm_( | |
setPublishing(true); | ||
const { identifier, ...draftData } = | ||
form.getFieldsValue(); | ||
await api.updateCmsRow(row.id, { | ||
identifier, | ||
data: draftData, | ||
draftData: null, | ||
revision, | ||
}); | ||
await mutateRow(); | ||
setPublishing(false); | ||
setHasUnpublishedChanges(false); | ||
await message.success({ | ||
content: "Your changes have been published.", | ||
duration: 5, | ||
}); | ||
const hooks = table.settings?.webhooks?.filter( | ||
(hook) => hook.event === "publish" | ||
); | ||
if (hooks && hooks.length > 0) { | ||
const hooksResp = await api.triggerCmsTableWebhooks( | ||
table.id, | ||
"publish" | ||
); | ||
const failed = hooksResp.responses.filter( | ||
(r) => r.status !== 200 | ||
try { | ||
await api.updateCmsRow(row.id, { | ||
identifier, | ||
data: draftData, | ||
draftData: null, | ||
revision, | ||
}); | ||
await mutateRow(); | ||
setPublishing(false); | ||
setHasUnpublishedChanges(false); | ||
await message.success({ | ||
content: "Your changes have been published.", | ||
duration: 5, | ||
}); | ||
const hooks = table.settings?.webhooks?.filter( | ||
(hook) => hook.event === "publish" | ||
); | ||
if (failed.length > 0) { | ||
await message.warning({ | ||
content: "Some publish hooks failed.", | ||
duration: 5, | ||
}); | ||
if (hooks && hooks.length > 0) { | ||
const hooksResp = await api.triggerCmsTableWebhooks( | ||
table.id, | ||
"publish" | ||
); | ||
const failed = hooksResp.responses.filter( | ||
(r) => r.status !== 200 | ||
); | ||
if (failed.length > 0) { | ||
await message.warning({ | ||
content: "Some publish hooks failed.", | ||
duration: 5, | ||
}); | ||
} | ||
} | ||
} catch (err) { | ||
setPublishing(false); | ||
console.log(err); | ||
if (err.statusCode === 400) { | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Use 409
|
||
setNotVaildUniqueFields(JSON.parse(err.message)); | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Before assuming that the error is about uniqueness, you need a way to guarantee that it is a uniqueness issue, because we might have other kinds of errors, such as auth error, etc. Make a new error type in ApiSchema.
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I made the backend throw this UniqueViolationError type object, but on client side, the error is always UnknownApiErrors. And I found this comment in api.ts : |
||
} | ||
} | ||
} | ||
|
@@ -514,7 +576,8 @@ function CmsEntryDetailsForm_( | |
isPublishing || | ||
isSaving || | ||
hasUnsavedChanges || | ||
hasFormError() | ||
hasFormError() || | ||
isUniqueFieldChanged() | ||
} | ||
tooltip={ | ||
hasFormError() | ||
|
@@ -640,7 +703,8 @@ function CmsEntryDetailsForm_( | |
table!, | ||
database, | ||
database.extraData.locales, | ||
inConflict | ||
inConflict, | ||
notValidUniqueFields | ||
)} | ||
</div> | ||
} | ||
|
Original file line number | Diff line number | Diff line change |
---|---|---|
|
@@ -235,6 +235,14 @@ const FormNameContext = createContext< | |
{ name: NamePathz; label: ReactNode } | undefined | ||
>(undefined); | ||
|
||
export class FormValidationError extends Error { | ||
type: string; | ||
constructor(message: string, type: string) { | ||
super(message); | ||
this.type = type; | ||
} | ||
} | ||
|
||
function MaybeFormItem({ | ||
typeName, | ||
name, | ||
|
@@ -245,9 +253,30 @@ function MaybeFormItem({ | |
name: NamePathz; | ||
maxChars?: number; | ||
minChars?: number; | ||
// unique?: boolean; | ||
uniqueNotValid?: boolean; | ||
}) { | ||
type FieldStatus = "success" | "warning" | "error" | "validating" | undefined; | ||
const [fieldStatus, setFieldStatus] = React.useState<FieldStatus>("success"); | ||
const [helperText, setHelperText] = React.useState(" "); | ||
const commonRules = [ | ||
Comment on lines
+259
to
262
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Try to get rid of these states and use the rules framework. Here's the docs: https://ant.design/components/form#rule |
||
{ required: props.required, message: "Field is required" }, | ||
{ | ||
validator: async (_, value) => { | ||
if (props.required && value.length === 0) { | ||
setFieldStatus("error"); | ||
setHelperText("Field is required"); | ||
return Promise.reject(); | ||
} | ||
if (props.uniqueNotValid) { | ||
setFieldStatus("warning"); | ||
setHelperText("This field should be unique to publish this entry"); | ||
return Promise.resolve(); | ||
} | ||
setFieldStatus("success"); | ||
setHelperText(""); | ||
return Promise.resolve(); | ||
}, | ||
}, | ||
]; | ||
const typeSpecificRules = | ||
[CmsMetaType.TEXT, CmsMetaType.RICH_TEXT].includes(typeName) && | ||
|
@@ -256,13 +285,19 @@ function MaybeFormItem({ | |
: []; | ||
|
||
const rules = [...commonRules, ...typeSpecificRules]; | ||
|
||
return typeName === CmsMetaType.LIST ? ( | ||
<FormNameContext.Provider value={{ name, label }}> | ||
{props.children as any} | ||
</FormNameContext.Provider> | ||
) : ( | ||
<Form.Item name={name} label={label} {...props} rules={rules} /> | ||
<Form.Item | ||
name={name} | ||
label={label} | ||
{...props} | ||
rules={rules} | ||
help={helperText} | ||
validateStatus={fieldStatus} | ||
/> | ||
); | ||
} | ||
|
||
|
@@ -276,7 +311,6 @@ export function CmsObjectInput(props: any) { | |
} = useContentEntryFormContext(); | ||
assert(typeMeta.type === CmsMetaType.OBJECT, "Must be rendering an object"); | ||
const form = Form.useFormInstance(); | ||
|
||
return ( | ||
<div | ||
style={ | ||
|
@@ -572,6 +606,8 @@ interface MaybeLocalizedInputProps { | |
fieldPathSuffix: string[]; | ||
formItemProps: FormItemProps; | ||
typeName: CmsTypeName; | ||
// unique: boolean; | ||
uniqueNotValid: boolean; | ||
} | ||
|
||
export function renderMaybeLocalizedInput({ | ||
|
@@ -584,6 +620,7 @@ export function renderMaybeLocalizedInput({ | |
formItemProps, | ||
typeName, | ||
required, | ||
uniqueNotValid, | ||
}: MaybeLocalizedInputProps) { | ||
return ( | ||
<ContentEntryFormContext.Consumer> | ||
|
@@ -608,6 +645,7 @@ export function renderMaybeLocalizedInput({ | |
minChars={minChars} | ||
required={required} | ||
typeName={typeName} | ||
uniqueNotValid={uniqueNotValid} | ||
{...formItemProps} | ||
name={[...fieldPath, "", ...fieldPathSuffix]} | ||
> | ||
|
@@ -643,6 +681,7 @@ export function renderMaybeLocalizedInput({ | |
maxChars={maxChars} | ||
minChars={minChars} | ||
required={required} | ||
uniqueNotValid={uniqueNotValid} | ||
typeName={typeName} | ||
name={[...fieldPath, locale, ...fieldPathSuffix]} | ||
noStyle | ||
|
Original file line number | Diff line number | Diff line change |
---|---|---|
|
@@ -248,6 +248,11 @@ function renderModelFieldForm( | |
<ValueSwitch /> | ||
</Form.Item> | ||
)} | ||
{![CmsMetaType.LIST, CmsMetaType.OBJECT].includes(selectedType) && ( | ||
<Form.Item label={"Unique"} name={[...fieldPath, "unique"]} required> | ||
<ValueSwitch /> | ||
</Form.Item> | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. What happens if you check the unique switch, then change the type to a list or object? Similar for localized in the future. |
||
)} | ||
<Form.Item | ||
label={"Helper text"} | ||
name={[...fieldPath, "helperText"]} | ||
|
@@ -647,6 +652,7 @@ function ModelFields({ | |
helperText: "", | ||
required: false, | ||
hidden: false, | ||
unique: false, | ||
type: CmsMetaType.TEXT, | ||
defaultValueByLocale: {}, | ||
}) | ||
|
Original file line number | Diff line number | Diff line change |
---|---|---|
|
@@ -59,6 +59,7 @@ import { | |
upsertDatabaseTables, | ||
} from "@/wab/server/routes/cms"; | ||
import { | ||
checkUniqueness, | ||
cloneDatabase, | ||
cloneRow, | ||
cmsFileUpload, | ||
|
@@ -828,6 +829,7 @@ export function addCmsEditorRoutes(app: express.Application) { | |
app.put("/api/v1/cmse/rows/:rowId", withNext(updateRow)); | ||
app.delete("/api/v1/cmse/rows/:rowId", withNext(deleteRow)); | ||
app.post("/api/v1/cmse/rows/:rowId/clone", withNext(cloneRow)); | ||
app.post("/api/v1/cmse/rows/:rowId/uniqueness", withNext(checkUniqueness)); | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Let's call it /check-unique-fields |
||
app.get("/api/v1/cmse/row-revisions/:revId", withNext(getRowRevision)); | ||
|
||
app.post( | ||
|
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Change return type of
checkUnique
to make this simpler.