Skip to content

Add 'unique' to model schema - new feature that checks unique constraints when saving/publishing #135

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

Open
wants to merge 40 commits into
base: master
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
40 commits
Select commit Hold shift + click to select a range
4281456
rebase - branch for uniqueness-check
hyunnbunt Feb 7, 2025
c65a0c1
warning message works
hyunnbunt Mar 10, 2025
d6fc528
publish button disabled, some function separated
hyunnbunt Mar 11, 2025
94f2d5b
.
hyunnbunt Mar 11, 2025
b11b6b6
.
hyunnbunt Mar 11, 2025
16c87db
removed log
hyunnbunt Mar 11, 2025
a74c868
removed unused err
hyunnbunt Mar 12, 2025
7ab0716
revert unintended changes
hyunnbunt Mar 12, 2025
be7c73b
-
hyunnbunt Mar 12, 2025
a9928d2
undefined to empty string
hyunnbunt Mar 12, 2025
0447ac1
-
hyunnbunt Mar 12, 2025
19d1cff
add comment, checkUniqueness
hyunnbunt Mar 12, 2025
ce2a225
backend code changes after code review
hyunnbunt Mar 13, 2025
575ae6b
link to conflicting row
hyunnbunt Mar 16, 2025
09a5053
link to conflicting row
hyunnbunt Mar 16, 2025
d942762
removed LocaleData type
hyunnbunt Mar 16, 2025
80966f0
unit test for conflictingCmsRowIds
hyunnbunt Mar 20, 2025
7c0b5fc
ignoring validateFields error, load message while checking uniqueness
hyunnbunt Mar 20, 2025
8357986
.
hyunnbunt Mar 20, 2025
ac582ee
ValueSwitch of localized/unique disabled if the other is on
hyunnbunt Mar 21, 2025
1177326
Switch has message when disabled
hyunnbunt Mar 21, 2025
e341d09
multiple nulls are allowed for unique field
hyunnbunt Mar 24, 2025
d1c959c
getConflictingCmsRowIds util test
hyunnbunt Apr 1, 2025
27bc581
getConflictingCmsRowIds util test
hyunnbunt Apr 1, 2025
511b2fa
unique-check API test
hyunnbunt Apr 2, 2025
7c923ea
removed the states of unique/localize d and used form instance
hyunnbunt Apr 2, 2025
f5828f7
unique set false when the field is list or object
hyunnbunt Apr 4, 2025
00d7875
unique-check api test
hyunnbunt Apr 4, 2025
1490d5c
removed normalizeData function
hyunnbunt Apr 5, 2025
87a3599
api test - unique-check
hyunnbunt Apr 5, 2025
fe6d401
revert db-setup.bash
hyunnbunt Apr 5, 2025
3cf0ce7
test, url, conflictRowIds undefined check changes
hyunnbunt Apr 7, 2025
34f15a0
split a test
hyunnbunt Apr 7, 2025
3f3066e
unique-check api 4 tests
hyunnbunt Apr 8, 2025
04e43f3
publish test in a separate file
hyunnbunt Apr 8, 2025
7cfcdbf
publish and unique-check test in a same file
hyunnbunt Apr 8, 2025
6c3fe1a
test for multiple empty values comparing
hyunnbunt Apr 8, 2025
f1e628d
.
hyunnbunt Apr 8, 2025
5384c29
.
hyunnbunt Apr 8, 2025
2b269f9
fix layout shift UX issue
hyunnbunt Apr 11, 2025
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
225 changes: 192 additions & 33 deletions platform/wab/src/wab/client/components/cms/CmsEntryDetails.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -21,16 +21,20 @@ import {
DefaultCmsEntryDetailsProps,
PlasmicCmsEntryDetails,
} from "@/wab/client/plasmic/plasmic_kit_cms/PlasmicCmsEntryDetails";
import { isUniqueViolationError } from "@/wab/shared/ApiErrors/cms-errors";
import {
ApiCmsDatabase,
ApiCmseRow,
ApiCmsTable,
CmsDatabaseId,
CmsFieldMeta,
CmsMetaType,
CmsRowData,
CmsRowId,
CmsTableId,
UniqueFieldCheck,
} from "@/wab/shared/ApiSchema";
import { getUniqueFieldsData } from "@/wab/shared/cms";
import { Dict } from "@/wab/shared/collections";
import { spawn } from "@/wab/shared/common";
import { DEVFLAGS } from "@/wab/shared/devflags";
Expand All @@ -44,6 +48,11 @@ import { Prompt, Route, useHistory, useRouteMatch } from "react-router";
import { useBeforeUnload, useInterval } from "react-use";

export type CmsEntryDetailsProps = DefaultCmsEntryDetailsProps;
export type UniqueFieldStatus = {
value: unknown;
status: "not started" | "pending" | "ok" | "violation";
conflictEntryIds: CmsRowId[];
};

function getRowIdentifierText(
table: ApiCmsTable,
Expand Down Expand Up @@ -119,7 +128,8 @@ export function renderContentEntryFormFields(
table: ApiCmsTable,
database: ApiCmsDatabase,
locales: string[],
disabled: boolean
disabled: boolean,
uniqueFieldStatus?: Dict<UniqueFieldStatus>
) {
return (
<>
Expand All @@ -145,6 +155,9 @@ export function renderContentEntryFormFields(
formItemProps: deriveFormItemPropsFromField(field),
typeName: field.type,
required: field.required,
uniqueStatus: uniqueFieldStatus
? uniqueFieldStatus[field.identifier]
: undefined,
...(isCmsTextLike(field)
? {
maxChars: field.maxChars,
Expand Down Expand Up @@ -187,6 +200,28 @@ function CmsEntryDetailsForm_(
const mutateRow_ = useMutateRow();
const mutateTableRows = useMutateTableRows();

const [uniqueFieldsStatus, setUniqueFieldsStatus] = React.useState<
Dict<UniqueFieldStatus>
>(initializeUniqueStatus());

const isSomeUniqueStatus = (status: string) => {
return Object.values(uniqueFieldsStatus).some(
(fieldStatus) => fieldStatus.status === status
);
};
const isUniqueFieldUpdated = React.useMemo(
() => isSomeUniqueStatus("not started"),
[uniqueFieldsStatus]
);
const isCheckingUniqueness = React.useMemo(
() => isSomeUniqueStatus("pending"),
[uniqueFieldsStatus]
);
const hasUniqueViolation = React.useMemo(
() => isSomeUniqueStatus("violation"),
[uniqueFieldsStatus]
);

const mutateRow = async () => {
const newRow = await mutateRow_(table.id, row.id);
if (newRow) {
Expand Down Expand Up @@ -293,6 +328,80 @@ function CmsEntryDetailsForm_(
setInConflict(true);
};

function dataToUniqueStatus(uniqueData: Dict<unknown>, status: string) {
const uniqueStatus = {};
Object.entries(uniqueData).forEach(([fieldIdentifier, fieldValue]) => {
uniqueStatus[fieldIdentifier] = {
value: fieldValue,
status: status,
conflictEntryIds: [],
} as UniqueFieldStatus;
});
return uniqueStatus as Dict<UniqueFieldStatus>;
}

function initializeUniqueStatus() {
const uniqueFieldsIdentifier = table.schema.fields
.filter((f) => f.unique)
.map((f) => f.identifier);
if (row.draftData) {
const uniqueDraftFields = getUniqueFieldsData(
uniqueFieldsIdentifier,
row.draftData as CmsRowData
);
return dataToUniqueStatus(uniqueDraftFields, "not started");
}
/** If the data equals the published data. */
const uniquePublishedFields = getUniqueFieldsData(
uniqueFieldsIdentifier,
row.data as CmsRowData
);
return dataToUniqueStatus(uniquePublishedFields, "ok");
}

async function checkUniqueness() {
const updatedUniqueFields = {};
setUniqueFieldsStatus((prev) => {
const copy = { ...prev };
Object.entries(copy).map(([fieldIdentifier, fieldStatus]) => {
if (fieldStatus.status === "not started") {
updatedUniqueFields[fieldIdentifier] = fieldStatus.value;
copy[fieldIdentifier].status = "pending";
}
});
return copy;
});
await message.loading({
key: "uniqueness-message",
content: `Checking uniqueness violation...`,
});
const uniqueFieldsChecked: UniqueFieldCheck[] = await api.checkUniqueFields(
table.id,
{
rowId: row.id,
uniqueFieldsData: updatedUniqueFields,
}
);
setUniqueFieldsStatus((prev) => {
const copy = { ...prev };
uniqueFieldsChecked.forEach((uniqueCheck) => {
const identifier = uniqueCheck.fieldIdentifier;
if (copy[identifier].value === uniqueCheck.value) {
copy[identifier].status = uniqueCheck.ok ? "ok" : "violation";
copy[identifier].conflictEntryIds = uniqueCheck.conflictRowIds;
}
});
return copy;
});
try {
await form.validateFields();
} catch (err) {
/* The validateFields function throws an error with details if any field has an error.
We are ignoring this error because we just want to trigger the field validation and
antd will automatically show warnings/errors to the user. */
}
}

async function performSave() {
const { identifier, ...draftData } = form.getFieldsValue();
try {
Expand Down Expand Up @@ -341,9 +450,14 @@ function CmsEntryDetailsForm_(
setRevision(row.revision);
await resetFormByRow();
}
} else if (hasChanges() && !hasFormError()) {
if (!isSaving) {
spawn(performSave());
} else {
if (!hasFormError()) {
if (hasChanges() && !isSaving) {
spawn(performSave());
}
}
if (isUniqueFieldUpdated && !isCheckingUniqueness) {
spawn(checkUniqueness());
}
}
}, 2000);
Expand Down Expand Up @@ -410,6 +524,24 @@ function CmsEntryDetailsForm_(
setHasUnsavedChanges(hasChanges());
setHasUnpublishedChanges(hasPublishableChanges());
console.log({ changedFields: changedValues, allFields: allValues });
setUniqueFieldsStatus((prev) => {
const uniqueFieldsIdentifier = table.schema.fields
.filter((f) => f.unique)
.map((f) => f.identifier);
const changedUniqueData = getUniqueFieldsData(
uniqueFieldsIdentifier,
changedValues as CmsRowData
);
const changedUniqueStatus = dataToUniqueStatus(
changedUniqueData,
"not started"
);
const copy = { ...prev };
Object.keys(changedUniqueStatus).forEach((fieldIdentifier) => {
copy[fieldIdentifier] = changedUniqueStatus[fieldIdentifier];
});
return copy;
});
}
}}
className={"max-scrollable fill-width"}
Expand Down Expand Up @@ -476,36 +608,60 @@ 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();
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) {
if (isUniqueViolationError(err)) {
const checkedUniqueFields: UniqueFieldCheck[] =
err.violations;
setUniqueFieldsStatus((prev) => {
const copy = { ...prev };
checkedUniqueFields.forEach((uniqueField) => {
copy[
uniqueField.fieldIdentifier
].conflictEntryIds = uniqueField.conflictRowIds;
copy[uniqueField.fieldIdentifier].value =
uniqueField.value;
copy[uniqueField.fieldIdentifier].status =
uniqueField.ok ? "ok" : "violation";
});
return copy;
});
await form.validateFields();
} else {
throw err;
}
} finally {
setPublishing(false);
}
}
}}
Expand All @@ -514,7 +670,9 @@ function CmsEntryDetailsForm_(
isPublishing ||
isSaving ||
hasUnsavedChanges ||
hasFormError()
hasFormError() ||
isCheckingUniqueness ||
hasUniqueViolation
}
tooltip={
hasFormError()
Expand Down Expand Up @@ -640,7 +798,8 @@ function CmsEntryDetailsForm_(
table!,
database,
database.extraData.locales,
inConflict
inConflict,
uniqueFieldsStatus
)}
</div>
}
Expand Down
Loading
Loading