Skip to content

make widget operations dynamic to add custom ones #4897

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

Merged
merged 52 commits into from
Apr 21, 2025

Conversation

ETLaurent
Copy link
Contributor

@ETLaurent ETLaurent commented Mar 31, 2025

TODO: check that other modules have not been broken with the menuItemClicked changed to pass an item object and not just item.action

Related to https://github.com/apostrophecms/testbed/pull/343

Summary

  • Implement the new apos.area.addWidgetOperation method to:
    • Add widget operations at first level
    • Add widget operations at secondary level (via 3-dots menu)
  • Add wrapper to the @apostrophecms/widget-type module to register operations only for widgets that match the type of the module where the wrapper is invoked.
  • Handle permission in apos.area.getBrowserData
  • Dynamically loop through widget operations
  • Update widget operation icons
  • Display secondary widget operations, by re-using AposContextMenu.vue
    • Adapt AposContextMenu.vue to make it emit the whole clicked item, not only its action property, in order to access the modal property.
    • Adapt this change everywhere (action -> item.action)
  • Lot's of eslint max-len warnings fixed (marked as "linter" in the PR comments) (done in Fix eslint warnings #4923)

What are the specific steps to test this change?

- Primary level

    apos.area.addWidgetOperation({
      name: 'openSettings',
      modal: 'AposSettingsManager',
      label: 'Create Section',
      icon: 'group-icon'
    });
image

- Secondary level

    apos.area.addWidgetOperation({
      name: 'createSections',
      modal: 'AposSettingsManager',
      label: 'Create Section',
      icon: 'group-icon',
      secondaryLevel: true
    });
image

- Specific widget type

    // in `modules/@apostrophecms/image-widget/index.js`
    self.addWidgetOperation({
      name: 'adjustImage',
      modal: 'AposImageRelationshipEditor',
      label: 'Adjust Image',
      icon: 'image-edit-outline',
      permission: {
        action: 'edit',
        type: '@apostrophecms/image'
      }
    });
image rich text
image image

What kind of change does this PR introduce?

(Check at least one)

  • Bug fix
  • New feature
  • Refactor
  • Documentation
  • Build-related changes
  • Other

Make sure the PR fulfills these requirements:

  • It includes a) the existing issue ID being resolved, b) a convincing reason for adding this feature, or c) a clear description of the bug it resolves
  • The changelog is updated
  • Related documentation has been updated
  • Related tests have been updated

If adding a new feature without an already open issue, it's best to open a feature request issue first and wait for approval before working on it.

Other information:

Copy link

linear bot commented Mar 31, 2025

}
if (modal) {
apos.modal.execute(modal, {
modelValue: this.modelValue
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

we'll just add additional data if needed

@ETLaurent ETLaurent force-pushed the pro-7247-widget-operations branch from 278bd9e to 64232c1 Compare April 8, 2025 15:03
Comment on lines +153 to +161
// Maintain a knowledge of which tray item toggles are active
const trayItem = this.trayItems.find(trayItem => trayItem.name === item.action);

if (trayItem && trayItem.options.toggle) {
this.trayItemState = {
...this.trayItemState,
[item.action]: !this.trayItemState[item.action]
};
}
Copy link
Contributor Author

@ETLaurent ETLaurent Apr 8, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

block moved from the late onAdminMenuClick (unnecessary? right?) event listener

@ETLaurent ETLaurent marked this pull request as draft April 17, 2025 09:16
@ETLaurent ETLaurent requested a review from ValJed April 17, 2025 15:25
@ETLaurent ETLaurent marked this pull request as ready for review April 17, 2025 15:57
Comment on lines +234 to +237
// TODO: make sure the update method from
// modules/@apostrophecms/area/ui/apos/components/AposAreaEditor.vue
// does the job and does not mess with the widget type and _id:
this.$emit('update', result.widget);
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copy link
Contributor

@ValJed ValJed left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Some suggestions / questions.

Also one thing is unclear to me:
When we create the widget operation backend side there is no action.
Does it refer to the name of the operation? If it's the case why not keeping the same name for both?
There is an action inside the permission object but I don't think it's related to what is called item.action on the frontend.

emitEvent(name) {
apos.bus.$emit('admin-menu-click', name);
emitEvent(item) {
apos.bus.$emit('admin-menu-click', item.action);
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

So item-clicked emits the entire item, but admin-menu-click emits only the action name?
Shouldn't we make it more consistent and emit the item all the time, maybe it's a lot more work?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We could, but in the end, the component that listens to admin-menu-click event will need to adapt, and so on.
Since the scope of this PR has already been way over, I prefer to limit the changes to the minimum.

@@ -138,11 +138,11 @@ export default {
togglePublishDraftMode() {
if (this.canTogglePublishDraftMode) {
const mode = this.draftMode === 'draft' ? 'published' : 'draft';
this.switchDraftMode(mode);
this.$emit('switch-draft-mode', mode);
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
this.$emit('switch-draft-mode', mode);
const action = this.draftMode === 'draft' ? 'published' : 'draft';
this.switchDraftMode({ action });


self.widgetOperations = self.widgetOperations.filter(
({ name }) => name !== operation.name
);
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is this to avoid duplicates? I think that throwing an error would be easier to debug and fix for users?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yes, this replaces an already-registered operation with the same name.
I took the same logic that's for customOperations:

self.contextOperations = [

Comment on lines +701 to +706
const widgetOperations = self.widgetOperations.filter(({ permission }) => {
if (permission?.action && permission?.type) {
return self.apos.permission.can(req, permission.action, permission.type, permission.mode || 'draft');
}
return true;
});
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
const widgetOperations = self.widgetOperations.filter(({ permission }) => {
if (permission?.action && permission?.type) {
return self.apos.permission.can(req, permission.action, permission.type, permission.mode || 'draft');
}
return true;
});
const widgetOperations = self.widgetOperations.filter(({ permission = {} }) => {
if (permission.action && permission.type) {
return self.apos.permission.can(req, permission.action, permission.type, permission.mode || 'draft');
}
return true;
});

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I like my version better 🍃

@@ -1,75 +1,36 @@
<template>
<div class="apos-area-modify-controls">
<AposButtonGroup
v-if="!foreign"
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

AposWidgetControls.vue is never rendered if foreign is falsy,
You should remove the prop foreign from the component AposWidgetControls it serves no purpose.

See:

        <AposWidgetControls
          v-if="!foreign" // Never rendered if foreign is falsy
          :first="i === 0"
          :last="i === next.length - 1"
          :options="{ contextual: isContextual }"
          :foreign="foreign" // Useless prop, can be removed
          :disabled="disabled"
          :max-reached="maxReached"
          :tabbable="isFocused"
          :model-value="widget"
          :area-field="field"
          @up="$emit('up', i);"
          @remove="$emit('remove', i);"
          @edit="$emit('edit', i);"
          @cut="$emit('cut', i);"
          @copy="$emit('copy', i);"
          @clone="$emit('clone', i);"
          @down="$emit('down', i);"
          @update="$emit('update')"
        />

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

done

.filter(operation => !operation.secondaryLevel),
widgetSecondaryOperations: filteredOperations
.filter(operation => operation.secondaryLevel)
};
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Optional but logic could be in a method

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

done

},
methods: {
filterByWidgetType(operation) {
return !operation.type || operation.type === this.modelValue.type;
},
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This method is never used, you could use it in data

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It was used.
But now it's been moved to the shared method, see 5873267

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Erratum.
It wasn't used 👍

widget: this.modelValue,
field: this.areaField
});
if (result.widget) {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Result can be undefined:

 if (result?.widget) {

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

well spotted

@@ -81,7 +81,6 @@
"chooseDocType": "{{ type }} auswählen",
"clear": "Löschen",
"clearSelection": "Auswahl löschen",
"clone": "Duplizieren",
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We don't have clone anymore?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

No, this is called "Duplicate..." now

@ETLaurent ETLaurent requested review from ValJed and boutell April 21, 2025 12:53
@ETLaurent
Copy link
Contributor Author

ETLaurent commented Apr 21, 2025

Also one thing is unclear to me:
When we create the widget operation backend side there is no action.
Does it refer to the name of the operation? If it's the case why not keeping the same name for both?
There is an action inside the permission object but I don't think it's related to what is called item.action on the frontend.

I am not sure I understand, but I can say that I started with not setting the name property, because I thought that modal would suffice (ie: that it was unique and served as the ID, like name).

But actually, we'd like to be able to add operations that have the same modal, with different props.
Hence the name.

Regarding action, I don't see where custom widgets operations would use it as it is expected to be a known string such as cut, clone and so on. Our custom operation would open modals, in my mind.

Indeed, permission's action is related to our permission system, as we usually provide an 'action' (create, edit publish etc) for a specific 'type':

self.apos.permission.can(req, permission.action, permission.type)

I hope this answers your question.

@ETLaurent ETLaurent changed the title dynamic widget operations dynamic custom widget operations Apr 21, 2025
@ETLaurent ETLaurent changed the title dynamic custom widget operations make widget operations dynamic to add custom ones Apr 21, 2025
Copy link
Contributor

@ValJed ValJed left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

LGTM 👍🏼

@ETLaurent ETLaurent merged commit 55b282e into main Apr 21, 2025
9 checks passed
@ETLaurent ETLaurent deleted the pro-7247-widget-operations branch April 21, 2025 14:17
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

Successfully merging this pull request may close these issues.

4 participants