Skip to content

Commit cb7b89e

Browse files
joshuablumclaudejasonvarga
authored
[6.x] Collaboration support (#13974)
Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com> Co-authored-by: Jason Varga <jason@pixelfear.com>
1 parent 7793b07 commit cb7b89e

File tree

7 files changed

+84
-8
lines changed

7 files changed

+84
-8
lines changed

packages/cms/src/index.js

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@ export const {
1919
requireElevatedSession,
2020
requireElevatedSessionIf,
2121
clone,
22+
debounce,
2223
deepClone,
2324
resetValuesFromResponse,
2425
} = __STATAMIC__.core;

resources/js/bootstrap/cms/core.js

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,3 +18,4 @@ export { default as HasActionsMixin } from '../../components/publish/HasActions.
1818
export { default as resetValuesFromResponse } from '../../util/resetValuesFromResponse.js';
1919
export { requireElevatedSession, requireElevatedSessionIf } from '../../components/elevated-sessions';
2020
export { default as clone, deepClone } from '../../util/clone.js';
21+
export { default as debounce } from '../../util/debounce.js';

resources/js/components/ui/Publish/Components.vue

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,5 +5,5 @@ const { components } = injectContainerContext();
55
</script>
66

77
<template>
8-
<component v-for="component in components" :key="component.name" :is="component.name" v-bind="component.props" />
8+
<component v-for="component in components" :key="component.name" :is="component.name" v-bind="component.props" v-on="component.events" />
99
</template>

resources/js/components/ui/Publish/Container.vue

Lines changed: 64 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -6,13 +6,13 @@ export const [injectContainerContext, provideContainerContext, containerContextK
66

77
<script setup>
88
import { nanoid as uniqid } from 'nanoid';
9-
import { watch, ref, computed, toRef, nextTick } from 'vue';
9+
import { onMounted, onUnmounted, watch, ref, computed, toRef, nextTick } from 'vue';
1010
import Component from '@/components/Component.js';
1111
import Tabs from './Tabs.vue';
1212
import Values from '@/components/publish/Values.js';
1313
import { data_get } from '@/bootstrap/globals.js';
1414
15-
const emit = defineEmits(['update:modelValue', 'update:visibleValues', 'update:modifiedFields']);
15+
const emit = defineEmits(['update:modelValue', 'update:visibleValues', 'update:modifiedFields', 'update:meta']);
1616
1717
const props = defineProps({
1818
name: {
@@ -179,6 +179,12 @@ watch(
179179
{ deep: true },
180180
);
181181
182+
watch(
183+
meta,
184+
(meta) => emit('update:meta', meta),
185+
{ deep: true },
186+
);
187+
182188
const avoidTrackingDirtyState = ref(false);
183189
const trackingDirtyState = computed(() => props.trackDirtyState && !avoidTrackingDirtyState.value)
184190
const isDirty = computed(() => Statamic.$dirty.has(props.name));
@@ -210,6 +216,10 @@ function setFieldValue(path, value) {
210216
data_set(values.value, path, value);
211217
}
212218
219+
function setMeta(newMeta) {
220+
meta.value = newMeta;
221+
}
222+
213223
function setFieldMeta(path, value) {
214224
data_set(meta.value, path, value);
215225
}
@@ -236,6 +246,30 @@ function removeLocalizedField(path) {
236246
if (index !== -1) localizedFields.value.splice(index, 1);
237247
}
238248
249+
const fieldFocus = ref({});
250+
251+
const fieldLocks = computed(() => {
252+
const locks = {};
253+
for (const { handle, user } of Object.values(fieldFocus.value)) {
254+
if (!locks[handle]) {
255+
locks[handle] = user;
256+
}
257+
}
258+
return locks;
259+
});
260+
261+
function focusField(handle, user = Statamic.user) {
262+
if (handle.includes('.')) throw new Error('focusField only supports top-level fields.');
263+
fieldFocus.value[user.id] = { handle, user };
264+
}
265+
266+
function blurField(handle, user = Statamic.user) {
267+
if (handle.includes('.')) throw new Error('blurField only supports top-level fields.');
268+
if (fieldFocus.value[user.id]?.handle === handle) {
269+
delete fieldFocus.value[user.id];
270+
}
271+
}
272+
239273
function pushComponent(name, { props }) {
240274
const component = new Component(uniqid(), name, props);
241275
components.value.push(component);
@@ -273,11 +307,16 @@ const builtInProvides = {
273307
isTrackingOriginValues: computed(() => !!props.originValues),
274308
setValues,
275309
setFieldValue,
310+
setMeta,
276311
setFieldMeta,
277312
setFieldPreviewValue,
278313
setRevealerField,
279314
unsetRevealerField,
280315
setHiddenField,
316+
fieldFocus,
317+
fieldLocks,
318+
focusField,
319+
blurField,
281320
isDirty,
282321
withoutDirtying,
283322
};
@@ -294,6 +333,28 @@ const provided = { ...additionalProvides, ...builtInProvides };
294333
295334
provideContainerContext({ ...provided, container: provided });
296335
336+
onMounted(() => {
337+
Statamic.$events.$emit('publish-container-created', {
338+
name: props.name,
339+
reference: props.reference,
340+
site: props.site,
341+
values,
342+
setFieldValue,
343+
setValues,
344+
meta,
345+
setMeta,
346+
setFieldMeta,
347+
pushComponent,
348+
fieldFocus,
349+
focusField,
350+
blurField,
351+
});
352+
});
353+
354+
onUnmounted(() => {
355+
Statamic.$events.$emit('publish-container-destroyed', { name: props.name });
356+
});
357+
297358
defineExpose({
298359
name: props.name,
299360
values,
@@ -306,6 +367,7 @@ defineExpose({
306367
pushComponent,
307368
visibleValues,
308369
setValues,
370+
setMeta,
309371
setExtraValues,
310372
});
311373

resources/js/components/ui/Publish/Field.vue

Lines changed: 13 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,9 @@ const {
4242
setFieldMeta,
4343
hiddenFields,
4444
setHiddenField,
45+
fieldLocks,
46+
focusField,
47+
blurField,
4548
container,
4649
direction,
4750
} = injectContainerContext();
@@ -112,11 +115,14 @@ watch(
112115
);
113116
114117
function focused() {
115-
// todo
118+
if (fieldPathPrefix.value) return;
119+
focusField(handle);
116120
}
117121
118-
function blurred() {
119-
// todo
122+
function blurred(event) {
123+
if (fieldPathPrefix.value) return;
124+
if (event?.currentTarget?.contains(event.relatedTarget)) return;
125+
blurField(handle);
120126
}
121127
122128
const values = computed(() => {
@@ -170,7 +176,8 @@ const isReadOnly = computed(() => {
170176
return isLocked.value || props.config.visibility === 'read_only' || false;
171177
});
172178
173-
const isLocked = computed(() => false); // todo
179+
const lockedBy = computed(() => fieldLocks.value[handle] ?? null);
180+
const isLocked = computed(() => lockedBy.value !== null && lockedBy.value.id !== Statamic.user.id);
174181
175182
const isSyncable = computed(() => {
176183
// Only top-level fields can be synced.
@@ -244,6 +251,7 @@ const fieldtypeComponentEvents = computed(() => ({
244251
<template v-else-if="config.hide_display">
245252
<span class="sr-only">{{ __(config.display) }}</span>
246253
</template>
254+
<ui-avatar v-if="isLocked" :user="lockedBy" class="rounded-full w-4 h-4 text-2xs" v-tooltip="lockedBy.name" />
247255
<ui-button size="xs" inset icon="synced" variant="ghost" v-tooltip="__('messages.field_synced_with_origin')" v-if="!isReadOnly && isSyncable" v-show="isSynced" @click="desync" />
248256
<ui-button size="xs" inset icon="unsynced" variant="ghost" v-tooltip="__('messages.field_desynced_from_origin')" v-if="!isReadOnly && isSyncable" v-show="!isSynced" @click="sync" />
249257
</Label>
@@ -254,7 +262,7 @@ const fieldtypeComponentEvents = computed(() => ({
254262
<div class="text-xs text-red-600" v-if="!fieldtypeComponentExists && fieldtypeComponent !== 'spacer-fieldtype'">
255263
Component <code v-text="fieldtypeComponent"></code> does not exist.
256264
</div>
257-
<div :dir="direction" v-if="fieldtypeComponentExists">
265+
<div :dir="direction" v-if="fieldtypeComponentExists" @focusin="focused" @focusout="blurred" :class="{ 'pointer-events-none select-none': isLocked }">
258266
<Component
259267
ref="fieldtype"
260268
:is="fieldtypeComponent"

resources/js/tests/FieldConditionsValidator.test.js

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,9 @@ const Statamic = {
2626
$dirty: {
2727
add: () => {},
2828
},
29+
$events: {
30+
$emit: () => {},
31+
},
2932
};
3033
window.Statamic = Statamic;
3134
window.__ = (msg) => msg;

resources/js/tests/Package.test.js

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,7 @@ it('exports core', async () => {
3535
'SaveButtonOptions',
3636
'SortableList',
3737
'clone',
38+
'debounce',
3839
'deepClone',
3940
'requireElevatedSession',
4041
'requireElevatedSessionIf',

0 commit comments

Comments
 (0)