Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
27 changes: 7 additions & 20 deletions resources/js/components/publish/Values.js
Original file line number Diff line number Diff line change
Expand Up @@ -83,33 +83,24 @@ export default class Values {

missingValue(dottedKey) {
var properties = Array.isArray(dottedKey) ? dottedKey : dottedKey.split('.');
var value = properties.reduce((prev, curr) => (prev == null ? undefined : prev[curr]), clone(this.values));
// Read-only walk — no need to clone. The constructor already made this.values private.
var value = properties.reduce((prev, curr) => (prev == null ? undefined : prev[curr]), this.values);

return value === undefined;
}

jsonDecodeValue(dottedKey) {
if (this.missingValue(dottedKey)) return;

let values = clone(this.values);
let fieldValue = data_get(values, dottedKey);
let decodedFieldValue = JSON.parse(fieldValue);

data_set(values, dottedKey, decodedFieldValue);

this.values = values;
let fieldValue = data_get(this.values, dottedKey);
data_set(this.values, dottedKey, JSON.parse(fieldValue));
}

jsonEncodeValue(dottedKey) {
if (this.missingValue(dottedKey)) return;

let values = clone(this.values);
let fieldValue = data_get(values, dottedKey);
let encodedFieldValue = JSON.stringify(fieldValue);

data_set(values, dottedKey, encodedFieldValue);

this.values = values;
let fieldValue = data_get(this.values, dottedKey);
data_set(this.values, dottedKey, JSON.stringify(fieldValue));
}

setValue(dottedKey, value) {
Expand All @@ -129,10 +120,6 @@ export default class Values {
forgetValue(dottedKey) {
if (this.missingValue(dottedKey)) return;

let values = clone(this.values);

data_delete(values, dottedKey);

this.values = values;
data_delete(this.values, dottedKey);
}
}
84 changes: 57 additions & 27 deletions resources/js/components/ui/LivePreview/LivePreview.vue
Original file line number Diff line number Diff line change
Expand Up @@ -66,22 +66,6 @@ const livePreviewFieldsPortal = computed(() => {
return `live-preview-fields-${name.value}`;
});

watch(
() => props.enabled,
(enabled, wasEnabled) => {
if (wasEnabled && !enabled) {
nextTick(() => (portalEnabled.value = false));
} else {
portalEnabled.value = enabled;
}

if (!enabled) return;

update();
animateIn();
},
);

const tokenizedUrl = computed(() => {
let url = props.url;

Expand All @@ -98,32 +82,33 @@ const payload = computed(() => ({
extras: extras.value,
}));

watch(
[payload, target],
(payload) => {
if (props.enabled) update();
},
{ deep: true },
);
// The payload is only watched while the preview is open, and a deep change that
// serializes to the payload we last posted doesn't warrant posting again. Explicit
// update() callers (open / popout / refresh) bypass this and always post.
let lastPostedPayloadKey = null;
let stopPayloadWatch = null;

const livePreviewDebounceMs = Statamic.$config.get('livePreview.debounce_ms', 150);

const update = debounce(() => {
const body = payload.value;
lastPostedPayloadKey = JSON.stringify([body, target.value]);

if (source) source.abort();
source = new AbortController();

loading.value = true;

axios
.post(tokenizedUrl.value, payload.value, { signal: source.signal })
.post(tokenizedUrl.value, body, { signal: source.signal })
.then((response) => {
token.value = response.data.token;
const url = response.data.url;
const tgt = toRaw(props.targets[target.value]);
const payload = { token: token.value, reference: props.reference };
const messagePayload = { token: token.value, reference: props.reference };
poppedOut.value
? channel.value.postMessage({ event: 'updated', url, target: tgt, payload })
: updateIframeContents(url, tgt, payload, setIframeAttributes);
? channel.value.postMessage({ event: 'updated', url, target: tgt, payload: messagePayload })
: updateIframeContents(url, tgt, messagePayload, setIframeAttributes);
loading.value = false;
})
.catch((e) => {
Expand Down Expand Up @@ -169,6 +154,50 @@ function animateOut() {
return wait(300);
}

function startPayloadWatch() {
if (stopPayloadWatch) return;

stopPayloadWatch = watch(
[payload, target],
() => {
const key = JSON.stringify([payload.value, target.value]);
if (key === lastPostedPayloadKey) return;

update();
},
{ deep: true },
);
}

function teardownPayloadWatch() {
stopPayloadWatch?.();
stopPayloadWatch = null;
update.cancel();
source?.abort();
}

watch(
() => props.enabled,
(enabled, wasEnabled) => {
if (wasEnabled && !enabled) {
teardownPayloadWatch();
nextTick(() => (portalEnabled.value = false));
} else {
portalEnabled.value = enabled;
}

if (!enabled) return;

startPayloadWatch();
update();
animateIn();
},
);

// The watcher above only covers transitions, so a component mounted already enabled
// needs the payload watch installed up front.
if (props.enabled) startPayloadWatch();

const canPopOut = computed(() => typeof BroadcastChannel === 'function');

function popout() {
Expand Down Expand Up @@ -303,6 +332,7 @@ const refreshEvent = `live-preview.${name.value}.refresh`;
Statamic.$events.$on(refreshEvent, refreshHandler);

onUnmounted(() => {
teardownPayloadWatch();
keybinding.value.destroy();
Statamic.$events.$off(refreshEvent, refreshHandler);
});
Expand Down
62 changes: 62 additions & 0 deletions resources/js/tests/PublishValues.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -563,6 +563,68 @@ test('it properly sets keys that javascript considers having numeric separators'
expect(newValues).toEqual(expected);
});

test('it never mutates the values it was constructed from', () => {
let values = {
first_name: 'Han',
ship: {
name: 'Falcon',
junk: true,
},
bffs: JSON.stringify([{ name: 'Chewy', type: 'Wookie' }]),
};

let original = JSON.parse(JSON.stringify(values));
let instance = new Values(values, ['bffs']);

instance.get('bffs.0.name');
instance.set('ship.name', 'Junker');
instance.set('bffs.0.type', 'Beast');
instance.jsonDecode();
instance.forgetValue('first_name');
instance.jsonEncode();
instance.except(['ship.junk']);

expect(values).toEqual(original);
});

test('it does not mutate the instance it merges dotted keys from', () => {
let source = new Values({ id: 'abc', title: 'Falcon' });
let target = new Values({ id: 'xyz', title: 'X-Wing' });

target.mergeDottedKeys(['id'], source);

expect(target.all()).toEqual({ id: 'abc', title: 'X-Wing' });
expect(source.all()).toEqual({ id: 'abc', title: 'Falcon' });
});

test('it does not mutate values when checking for a missing one', () => {
let instance = new Values({ ship: { name: 'Falcon' } });

expect(instance.missingValue('ship.crew')).toBe(true);
expect(instance.missingValue('ship.name')).toBe(false);
expect(instance.all()).toEqual({ ship: { name: 'Falcon' } });
});

test('it decodes and encodes a single json value in place', () => {
let instance = new Values({ bffs: JSON.stringify([{ name: 'Chewy' }]) }, ['bffs']);

instance.jsonDecodeValue('bffs');
expect(instance.all()).toEqual({ bffs: [{ name: 'Chewy' }] });

instance.jsonEncodeValue('bffs');
expect(instance.all()).toEqual({ bffs: JSON.stringify([{ name: 'Chewy' }]) });
});

test('it leaves values alone when decoding, encoding or forgetting a missing key', () => {
let instance = new Values({ ship: { name: 'Falcon' } });

instance.jsonDecodeValue('ship.crew');
instance.jsonEncodeValue('ship.crew');
instance.forgetValue('ship.crew');

expect(instance.all()).toEqual({ ship: { name: 'Falcon' } });
});

test('it does not throw when rejecting a value nested under a null node', () => {
let values = {
first_name: 'Han',
Expand Down
Loading
Loading