diff --git a/.gitignore b/.gitignore index 80d32241d0a..931fde135c0 100644 --- a/.gitignore +++ b/.gitignore @@ -19,6 +19,7 @@ vendor/ cpresources/* .ddev/ test-results/ +tests/Browser/Screenshots/ resources/hot # Test database(s) diff --git a/CHANGELOG.md b/CHANGELOG.md index f0805813e06..3b2da9ee59b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -64,6 +64,17 @@ ### Entries & Sections - Added the “Show the Post Date field” and “Show the Expiry Date field” entry type settings. ([#17675](https://github.com/craftcms/cms/pull/17675)) +### Workflows +- Added approval workflows, which control when drafts may be applied to entries. Workflows are configured from Settings → Workflows and assigned to sections. (Craft Pro and Enterprise only.) ([#19667](https://github.com/craftcms/cms/pull/19667)) +- Changes to enabled entries in sections with a workflow are now saved as drafts, which must be submitted for review and approved by each workflow stage before they can be applied. ([#19667](https://github.com/craftcms/cms/pull/19667)) +- Added the “User Review” workflow stage type, which supports per-group approval requirements, carried-over approvals, change requests, and email notifications for reviewers. ([#19667](https://github.com/craftcms/cms/pull/19667)) +- Added a “Workflow” element details tab, for submitting drafts for review, reviewing them, and viewing workflow activity history. ([#19667](https://github.com/craftcms/cms/pull/19667)) +- Added `CraftCms\Cms\Workflow\Contracts\WorkflowStageInterface` and `CraftCms\Cms\Workflow\Stages\WorkflowStage`, for creating custom workflow stage types. ([#19667](https://github.com/craftcms/cms/pull/19667)) +- Added `CraftCms\Cms\Workflow\WorkflowStageTypes`, for registering custom workflow stage types. ([#19667](https://github.com/craftcms/cms/pull/19667)) +- Added `CraftCms\Cms\Workflow\Contracts\WorkflowableInterface`, which element types can implement to support workflows. ([#19667](https://github.com/craftcms/cms/pull/19667)) +- Added `CraftCms\Cms\Workflow\Workflows` and `CraftCms\Cms\Support\Facades\Workflows`. ([#19667](https://github.com/craftcms/cms/pull/19667)) +- Added the `CraftCms\Cms\Workflow\Events\WorkflowTransitioning`, `WorkflowTransitioned`, and `WorkflowCommented` events. ([#19667](https://github.com/craftcms/cms/pull/19667)) + ### Elements - Added the `autoEagerLoadElements` general config setting (`true` by default), which determines whether element queries should be automatically lazy eager-loaded. ([#19637](https://github.com/craftcms/cms/pull/19637)) - Added Markdown comments to element activity timelines, with support for editing, removing, structured user mentions, and email notifications. diff --git a/composer.json b/composer.json index a9cb3037df9..6aa727229bc 100644 --- a/composer.json +++ b/composer.json @@ -234,7 +234,8 @@ "UserGroups": "CraftCms\\Cms\\Support\\Facades\\UserGroups", "UserPermissions": "CraftCms\\Cms\\Support\\Facades\\UserPermissions", "Users": "CraftCms\\Cms\\Support\\Facades\\Users", - "Volumes": "CraftCms\\Cms\\Support\\Facades\\Volumes" + "Volumes": "CraftCms\\Cms\\Support\\Facades\\Volumes", + "Workflows": "CraftCms\\Cms\\Support\\Facades\\Workflows" } } }, diff --git a/composer.lock b/composer.lock index 9854d798af5..ce3cbc5445d 100644 --- a/composer.lock +++ b/composer.lock @@ -4,7 +4,7 @@ "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "This file is @generated automatically" ], - "content-hash": "3887367016d6bd3ce5f508d6cdb58113", + "content-hash": "686663c3afb4226a327b8d56908b1167", "packages": [ { "name": "bacon/bacon-qr-code", diff --git a/docs/workflows.md b/docs/workflows.md new file mode 100644 index 00000000000..9b1e83c557c --- /dev/null +++ b/docs/workflows.md @@ -0,0 +1,362 @@ +# Approval workflows + +Approval workflows control when changes to an entry may be published. A workflow is an ordered list of stages assigned to a section. When an author submits a draft for review, Craft evaluates each stage in sequence and only allows the approved draft to be applied after every stage has approved it. + +Workflows require Craft Pro or Enterprise. + +## How workflows work + +Configure workflows under **Settings → Workflows**, then assign one to a section. Entries in sections without an assigned workflow retain the normal save and publish behavior. + +When an enabled entry has a workflow: + +1. Saving changes creates or updates a named draft instead of publishing directly to the canonical entry. +2. The author submits the draft for review from its **Workflow** tab. +3. Craft evaluates the first stage. +4. An approved stage advances the run to the next stage. A pending stage waits for a decision. A reviewer who requests changes fails a user-review stage until someone requests another review. +5. After every stage approves, an authorized user can apply the draft to the canonical entry. + +Disabled entries can be created and saved canonically without review. Enabling one routes the save into a draft, which can then be submitted for review. + +```mermaid +stateDiagram-v2 + [*] --> NotSubmitted: Save draft + NotSubmitted --> Pending: Request review + Pending --> Pending: Stage awaits a result + Pending --> Failed: Changes requested + Pending --> Approved: All stages approve + Pending --> Invalidated: Workflow changes or manual restart + Failed --> Pending: Request another review + Approved --> Pending: Draft changes reopen final stage + Approved --> Invalidated: Workflow changes or manual restart + Approved --> Published: Apply draft + Invalidated --> Pending: Request another review +``` + +Stages are evaluated in their configured order. Craft evaluates a stage when the run reaches it and whenever the stage reports new state. A stage returns one of three statuses: + +| Status | Result | +| ---------- | ----------------------------------------------------------------------- | +| `Pending` | Stop and wait at the current stage. | +| `Approved` | Continue to the next stage, or approve the run if this is the last one. | +| `Failed` | Stop the run with changes requested. | + +The built-in **User review** stage waits for approval from members of its configured user groups. It can require one or more approvals. The draft author cannot review their own work, and each reviewer can decide only once per stage until another review is requested. Requesting another review clears the current stage’s decisions but preserves approvals from earlier stages. + +### Runs use a configuration snapshot + +Submitting a draft creates a workflow run and stores a snapshot of the configured stages. The snapshot is retained for the run's execution and history, and is never updated in place. + +Editing an assigned workflow invalidates any pending or approved runs whose executable configuration changed. The draft must be submitted again to start a new run with the updated configuration. Names are descriptive and do not affect execution, so renaming a workflow or stage does not invalidate a run. + +### Draft changes and resets + +Changing publishable content does not restart a pending workflow. It clears approvals from the current user-review stage while preserving completed stages. External stage results can still arrive after the draft changes. + +If an approved draft changes, Craft reopens its final stage and clears that stage’s decisions. Earlier stages remain approved. The final stage is evaluated again, so an automated stage may approve immediately while a user-review stage waits for fresh approvals. + +An authorized editor can use **Restart workflow** to invalidate the current run and start a new run from the first stage. Workflow runs are also invalidated when: + +- the section's workflow assignment changes; +- a stage's type, order, or settings change; or +- the workflow is deleted. + +The draft remains available after invalidation. Published and invalidated runs and their activity history are retained. + +Drafts are initially locked in the editor while a review is pending or approved. An author can choose **Start editing** to unlock the draft without resetting completed stages. + +Applying an approved draft validates the current element state before publishing. If validation fails without changing the draft, the approval remains valid. Correcting publishable content reopens the final stage. + +### Permissions and write boundaries + +Submitting a draft requires `save` authorization, commenting requires `view` authorization, and applying an approved draft requires both `save` and `saveCanonical` authorization. Approval override is currently restricted to administrators. + +The control panel and other user-facing HTTP saves route workflow-controlled canonical changes into drafts. Craft does not globally intercept low-level element writes from plugins, console commands, or other trusted application code. Code that writes elements directly is responsible for choosing whether to create a draft or intentionally update the canonical element. + +## Creating a workflow stage type + +A plugin can add a stage type by implementing `WorkflowStageInterface`. Extending `WorkflowStage` is recommended because it supplies configurable-component behavior and default review UI behavior. + +This stage approves as soon as a workflow reaches it: + +```php +payload, + ); + } +} +``` + +Register it from the plugin's service provider: + +```php +use Acme\Editorial\Workflow\AutomaticApprovalStage; +use CraftCms\Cms\Workflow\WorkflowStageTypes; + +public function boot(WorkflowStageTypes $workflowStageTypes): void +{ + $workflowStageTypes->register(AutomaticApprovalStage::class); +} +``` + +Registered types appear in the stage type selector when an administrator configures a workflow. If a configured type becomes unavailable, Craft preserves its settings as a missing stage rather than discarding project config. The stage remains pending until its plugin is restored or the workflow is reconfigured. + +### Stage context and results + +`WorkflowStageContext` gives a stage the state needed to make its decision: + +| Property | Description | +| ---------------- | --------------------------------------------------------------------- | +| `draft` | The named draft under review. | +| `run` | The current `WorkflowRun` model. | +| `stage` | The snapshotted `WorkflowStageData` for this stage. | +| `payload` | Stage-owned data persisted from its previous result in the run. | +| `previousStages` | The preceding snapshotted stages and the payload stored for each one. | + +`evaluate()` returns a `WorkflowStageResult` containing a `WorkflowStageStatus`, a human-readable message, and the next payload. Payloads must contain JSON-encodable values. Craft stores each payload under the stage UID, isolating one stage's state from the others. + +Use the payload for durable state needed by later evaluations or by the stage's review UI. Do not put secrets in it; workflow state and activity are application data. + +### Configurable stages + +Stage classes use the same configurable-component conventions as other Craft components. Define public settings properties, validation rules, and an optional settings form: + +```php +use CraftCms\Cms\Form\Controls\Text; +use CraftCms\Cms\Form\Form; +use CraftCms\Cms\Form\FormContext; +use CraftCms\Cms\Form\Nodes\Field; +use CraftCms\Cms\Workflow\Stages\WorkflowStage; + +class WebhookApprovalStage extends WorkflowStage +{ + public string $endpoint = ''; + + public function getRules(): array + { + return [ + 'endpoint' => ['required', 'url:https'], + ]; + } + + public function settingsForm(FormContext $context = new FormContext): Form + { + return Form::make([ + Field::make('Endpoint', Text::make('endpoint'))->required(), + ]); + } + + // ... +} +``` + +Craft serializes public component settings into project config and validates them when the workflow is saved. Return `null` from `settingsForm()` when the type has no settings UI. + +## Waiting for an external result + +An integration stage can return `Pending` while it waits for a webhook, queue job, or another external system: + +```php +public function evaluate(WorkflowStageContext $context): WorkflowStageResult +{ + $requestId = $context->payload['requestId'] ?? null; + + if ($requestId === null) { + $requestId = $this->client->requestReview($context->draft); + } + + return new WorkflowStageResult( + status: WorkflowStageStatus::Pending, + message: 'Waiting for external approval', + payload: ['requestId' => $requestId], + ); +} +``` + +When the external decision arrives, report it through the `Workflows` facade rather than updating a run directly: + +```php +use CraftCms\Cms\Support\Facades\Workflows; +use CraftCms\Cms\Workflow\Data\WorkflowStageContext; +use CraftCms\Cms\Workflow\Data\WorkflowStageResult; +use CraftCms\Cms\Workflow\Enums\WorkflowStageStatus; + +$run = Workflows::reportStageResult( + runId: $runId, + stageUid: $stageUid, + result: function (WorkflowStageContext $context) use ($externalRequestId): WorkflowStageResult { + if (($context->payload['requestId'] ?? null) !== $externalRequestId) { + return new WorkflowStageResult( + WorkflowStageStatus::Pending, + 'Ignored a stale response', + $context->payload, + ); + } + + return new WorkflowStageResult( + WorkflowStageStatus::Approved, + 'Approved by the external service', + $context->payload, + ); + }, +); +``` + +The callback receives the current run, stage, and payload while Craft holds the workflow lock. This lets the integration compare external identifiers with current payload state without a read-then-write race. `reportStageResult()` only changes a run when the supplied stage is still its current pending stage; stale responses do not advance a newer run or stage. + +Authenticate and authorize webhook or control panel endpoints before calling the facade. `reportStageResult()` protects workflow consistency, but it does not establish whether an incoming caller is trusted. + +## Adding stage-specific review UI + +A stage can optionally provide Vue components for its pending actions and timeline summary: + +```php +use CraftCms\Cms\User\Contracts\CraftUser; +use CraftCms\Cms\Workflow\Data\WorkflowStageContext; + +public function actionComponent(): ?string +{ + return 'acme:external-approval-actions'; +} + +public function actionProps(WorkflowStageContext $context, CraftUser $viewer): array +{ + return [ + 'requestId' => $context->payload['requestId'] ?? null, + 'canDecide' => $viewer->can('approveExternalReviews'), + ]; +} + +public function summaryComponent(): ?string +{ + return 'acme:external-approval-summary'; +} + +public function summaryProps(WorkflowStageContext $context, CraftUser $viewer): array +{ + return [ + 'requestId' => $context->payload['requestId'] ?? null, + ]; +} +``` + +Register those components from the plugin's control panel JavaScript entry point: + +```typescript +import ExternalApprovalActions from "./ExternalApprovalActions.vue"; +import ExternalApprovalSummary from "./ExternalApprovalSummary.vue"; + +Cp.booting((cp) => { + cp.$components.register( + "acme:external-approval-actions", + ExternalApprovalActions, + ); + cp.$components.register( + "acme:external-approval-summary", + ExternalApprovalSummary, + ); +}); +``` + +Register the components before the Inertia application mounts. Use a plugin-prefixed component name to avoid collisions. `actionProps()` and `summaryProps()` must return JSON-safe values because they are sent to the control panel. + +Craft passes the action component its configured action props together with `review`, `elementType`, `elementId`, `draftId`, and `siteId`. After a successful action, emit `reviewUpdated` with the updated workflow review data and editor actions returned by the endpoint. Craft passes a summary component its configured summary props together with the containing `run` and `stage`. + +The action component is rendered above Craft's default comment and override controls for the current pending stage. The summary component is rendered in that stage's timeline for current and previous runs. To replace rather than supplement the default controls, set the inherited property in the stage class: + +```php +protected bool $showDefaultReviewActions = false; +``` + +Plugin action endpoints remain responsible for authorization and input validation. They should translate a valid decision into a `WorkflowStageResult` through `Workflows::reportStageResult()`. + +## Listening for workflow activity + +Craft dispatches lifecycle events for plugins that need to enforce policy or react to completed activity. Event coverage depends on the transition: + +| Event | Timing | +| ----------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `WorkflowTransitioning` | Synchronously before `Submit`, `Override`, `Approve`, `Reject`, `RequestReview`, or `Restart`; may cancel the transition. | +| `WorkflowTransitioned` | After commit for those transitions, invalidation, and an `Approved` or `Failed` result reported for an asynchronous stage through `reportStageResult()`. | +| `WorkflowCommented` | After a workflow comment's transaction commits. | + +Set `$event->cancel = true` in a `WorkflowTransitioning` listener to prevent the transition. Keep pre-transition listeners synchronous and side-effect free, because the surrounding transaction can still roll back. Use `WorkflowTransitioned` for notifications and other effects that should happen only after a successful commit. + +```php +use CraftCms\Cms\Workflow\Enums\WorkflowTransition; +use CraftCms\Cms\Workflow\Events\WorkflowTransitioning; +use Illuminate\Support\Facades\Event; + +Event::listen(function (WorkflowTransitioning $event): void { + if ($event->transition !== WorkflowTransition::Submit) { + return; + } + + if (! $this->reviewWindow->isOpen()) { + $event->cancel = true; + } +}); +``` + +Transitions and workflow activity use `WorkflowTransition`: `Submit`, `Override`, `Approve`, `Reject`, `RequestReview`, `Restart`, `StageApproved`, `StageFailed`, `Invalidate`, and `Publish`. Applying an approved draft records `Publish` activity but does not dispatch a workflow lifecycle event. Automatic stage results evaluated synchronously while submitting or advancing a run also record activity without dispatching a separate lifecycle event. Comments are separate because adding a comment does not change workflow state. + +## Supporting another element type + +Entries provide Craft's built-in workflow assignment UI. A plugin-owned element type can opt into the workflow runtime by implementing `WorkflowableInterface`: + +```php +use CraftCms\Cms\Element\Element; +use CraftCms\Cms\Workflow\Contracts\WorkflowableInterface; +use CraftCms\Cms\Workflow\Models\Workflow; + +class Release extends Element implements WorkflowableInterface +{ + public static function hasDrafts(): true + { + return true; + } + + public function workflow(): ?Workflow + { + return $this->workflowId === null + ? null + : Workflow::find($this->workflowId); + } +} +``` + +The element type must support drafts, and `workflow()` must return its assigned workflow or `null`. The plugin owns storage and configuration UI for that assignment. Implementing the contract lets Craft resolve review data, workflow-aware editor actions, and approved draft application; it does not add an assignment field to the element type's settings automatically. + +## Testing a custom stage + +Test the behavior at the stage and workflow boundaries: + +- Given representative contexts and payloads, assert that `evaluate()` returns the correct status, message, and payload. +- Test both sides of each approval boundary, including stale or mismatched external response IDs. +- For an asynchronous stage, verify that a result only affects the matching current stage and pending run. +- Test endpoint authentication and authorization separately from stage evaluation. +- If the stage has custom UI, test user-visible choices and submitted results rather than Vue implementation details. + +Avoid asserting database implementation details when the public result is what matters. A useful workflow test proves whether a draft remains pending, fails, advances, becomes approved, or can be applied. diff --git a/packages/craftcms-ui/src/components/button/button.test.ts b/packages/craftcms-ui/src/components/button/button.test.ts index 8f274defaef..c525966b804 100644 --- a/packages/craftcms-ui/src/components/button/button.test.ts +++ b/packages/craftcms-ui/src/components/button/button.test.ts @@ -117,6 +117,16 @@ describe('craft-button link semantics', () => { expect(element.getAttribute('role')).toBe('button'); }); + it('can remain focusable when disabled', async () => { + const element = await createButton({ + disabled: '', + 'focusable-when-disabled': '', + }); + + expect(element.getAttribute('aria-disabled')).toBe('true'); + expect(element.tabIndex).toBe(0); + }); + it('does not submit a form when a link-mode button is clicked', async () => { const form = document.createElement('form'); const element = document.createElement('craft-button') as CraftButton; diff --git a/packages/craftcms-ui/src/components/button/button.ts b/packages/craftcms-ui/src/components/button/button.ts index 4a6062939e4..2db49501a6c 100644 --- a/packages/craftcms-ui/src/components/button/button.ts +++ b/packages/craftcms-ui/src/components/button/button.ts @@ -134,6 +134,17 @@ export default class CraftButton extends Actionable(LionButtonSubmit) { this.syncLinkHostState(); } + if ( + changedProperties.has('disabled') || + changedProperties.has('focusableWhenDisabled') + ) { + if (this.disabled) { + this.tabIndex = this.focusableWhenDisabled ? 0 : -1; + } else if (!this.isLink) { + this.tabIndex = 0; + } + } + // The spinner is this button's rendering of the mixin's state. `loading` // stays public and settable on its own, for a caller showing one without // a declarative action behind it — so this follows transitions only. @@ -336,6 +347,10 @@ export default class CraftButton extends Actionable(LionButtonSubmit) { /** Show a spinner instead of the label */ @property({reflect: true, type: Boolean}) loading: boolean = false; + /** Keep the button in the tab order when disabled. */ + @property({attribute: 'focusable-when-disabled', type: Boolean}) + focusableWhenDisabled: boolean = false; + /** * Pulls the button out by the space around its content, so its label or * icon lines up with the text beside it. Meant for buttons with no diff --git a/packages/craftcms-ui/src/components/card/card.styles.ts b/packages/craftcms-ui/src/components/card/card.styles.ts index b614aa25e67..a587a198dec 100644 --- a/packages/craftcms-ui/src/components/card/card.styles.ts +++ b/packages/craftcms-ui/src/components/card/card.styles.ts @@ -81,8 +81,8 @@ export default css` .card-body { gap: var(--c-spacing-md); - padding-inline: var(--c-card-padding-inline, var(--c-spacing-md)); - padding-block: var(--c-card-padding-block, var(--c-spacing-md)); + padding-inline: var(--c-card-padding-inline, var(--cp-container-padding)); + padding-block: var(--c-card-padding-block, var(--cp-container-padding)); } .card-body--thumb-start { diff --git a/packages/craftcms-ui/src/components/field-group/field-group.browser.test.ts b/packages/craftcms-ui/src/components/field-group/field-group.browser.test.ts new file mode 100644 index 00000000000..c309b9eae4b --- /dev/null +++ b/packages/craftcms-ui/src/components/field-group/field-group.browser.test.ts @@ -0,0 +1,85 @@ +import {beforeEach, expect, it} from 'vite-plus/test'; +import './field-group.js'; +import '../select/select.js'; + +beforeEach(() => { + document.body.innerHTML = ''; +}); + +it('uses intrinsic widths when space allows and stacks them when it does not', async () => { + const fieldGroup = document.createElement('craft-field-group'); + fieldGroup.classList.add('auto-widths'); + fieldGroup.style.width = '720px'; + fieldGroup.style.setProperty('--c-spacing-lg', '24px'); + fieldGroup.style.setProperty('--c-spacing-sm', '8px'); + fieldGroup.innerHTML = ` +
Minimum
+
Mode
+ `; + document.body.append(fieldGroup); + await fieldGroup.updateComplete; + + const minimum = fieldGroup.querySelector('div:not([class])')!; + const mode = fieldGroup.querySelector('.width-25')!; + expect(minimum.getBoundingClientRect().width).toBeLessThan( + mode.getBoundingClientRect().width + ); + expect(minimum.getBoundingClientRect().top).toBe( + mode.getBoundingClientRect().top + ); + expect( + mode.getBoundingClientRect().left - minimum.getBoundingClientRect().right + ).toBeGreaterThanOrEqual(8); + + fieldGroup.style.width = '390px'; + await new Promise((resolve) => requestAnimationFrame(() => resolve(null))); + + expect(mode.getBoundingClientRect().top).toBeGreaterThan( + minimum.getBoundingClientRect().top + ); +}); + +it('sizes an intrinsic select for its longest option', async () => { + const fieldGroup = document.createElement('craft-field-group'); + fieldGroup.classList.add('auto-widths'); + fieldGroup.style.width = '900px'; + fieldGroup.style.setProperty('--c-spacing-sm', '8px'); + fieldGroup.innerHTML = ` +
Minimum
+
+ + + +
+ `; + document.body.append(fieldGroup); + await fieldGroup.updateComplete; + + const [minimum, mode] = fieldGroup.children as unknown as [ + HTMLElement, + HTMLElement, + ]; + const select = mode.querySelector('craft-select')!; + const selectedOptionWidth = document.createElement('craft-select'); + selectedOptionWidth.style.width = 'max-content'; + selectedOptionWidth.innerHTML = ` + + `; + document.body.append(selectedOptionWidth); + await Promise.all([ + select.updateComplete, + selectedOptionWidth.updateComplete, + ]); + + expect(select.getBoundingClientRect().width).toBeGreaterThan( + selectedOptionWidth.getBoundingClientRect().width + ); + expect(mode.getBoundingClientRect().top).toBe( + minimum.getBoundingClientRect().top + ); +}); diff --git a/packages/craftcms-ui/src/components/field-group/field-group.ts b/packages/craftcms-ui/src/components/field-group/field-group.ts index eb6e07ba8b2..b208fca4a6f 100644 --- a/packages/craftcms-ui/src/components/field-group/field-group.ts +++ b/packages/craftcms-ui/src/components/field-group/field-group.ts @@ -7,7 +7,7 @@ export default class CraftFieldGroup extends LitElement { craft-field-group { display: grid; grid-template-columns: repeat(12, minmax(0, 1fr)); - gap: var(--gap, var(--c-spacing-lg)) 0; + gap: var(--gap, var(--c-spacing-lg)) var(--c-spacing-sm); container-type: inline-size; } @@ -16,7 +16,20 @@ export default class CraftFieldGroup extends LitElement { min-width: 0; } + craft-field-group.auto-widths > :not([class*='width-']) { + width: fit-content; + } + + craft-field-group.auto-widths > :not([class*='width-']) craft-select { + width: max-content; + max-width: 100%; + } + @container (min-width: 30rem) { + craft-field-group.auto-widths > :not([class*='width-']) { + grid-column: span 2; + } + craft-field-group > .width-25 { grid-column: span 3; } @@ -39,6 +52,15 @@ export default class CraftFieldGroup extends LitElement { } @container (min-width: 50rem) { + craft-field-group.auto-widths > :not([class*='width-']) { + grid-column: span 1; + } + + craft-field-group.auto-widths + > :not([class*='width-']):has(craft-select) { + grid-column: span 2; + } + craft-field-group > .width-25 { grid-column: span 3; } diff --git a/packages/craftcms-ui/src/components/timeline-item/timeline-item.styles.ts b/packages/craftcms-ui/src/components/timeline-item/timeline-item.styles.ts index dcebd374399..f59f4498c82 100644 --- a/packages/craftcms-ui/src/components/timeline-item/timeline-item.styles.ts +++ b/packages/craftcms-ui/src/components/timeline-item/timeline-item.styles.ts @@ -57,8 +57,8 @@ export default css` } .timeline-item__header { - display: flex; - flex-wrap: wrap; + display: grid; + grid-template-columns: minmax(0, 1fr) auto; align-items: center; column-gap: var(--c-spacing-xs); } diff --git a/packages/craftcms-ui/src/components/timeline-item/timeline-item.test.ts b/packages/craftcms-ui/src/components/timeline-item/timeline-item.test.ts index 9f06b7f464d..a61886242a7 100644 --- a/packages/craftcms-ui/src/components/timeline-item/timeline-item.test.ts +++ b/packages/craftcms-ui/src/components/timeline-item/timeline-item.test.ts @@ -69,6 +69,12 @@ describe('craft-timeline-item', () => { ).toBeNull(); }); + it('keeps a heading and its metadata in separate grid columns', () => { + expect(styles.cssText).toContain( + 'grid-template-columns: minmax(0, 1fr) auto' + ); + }); + it('tracks optional content added after connection', async () => { const element = await createTimelineItem('

Content

'); const heading = document.createElement('h2'); diff --git a/packages/craftcms-ui/src/styles/form.styles.ts b/packages/craftcms-ui/src/styles/form.styles.ts index 28570befc85..4b4bedebf50 100644 --- a/packages/craftcms-ui/src/styles/form.styles.ts +++ b/packages/craftcms-ui/src/styles/form.styles.ts @@ -85,11 +85,8 @@ export const baseComboboxStyles = css` `; export const baseFieldStyles = css` - :host(:not([label-sr-only])) - .form-field__group-one - .form-field__label - slot:not(:empty) { - margin-block-end: var(--c-spacing-sm); + :host(:not([label-sr-only])) ::slotted(label:not(:empty)) { + margin-block-end: var(--c-spacing-md, 0.5rem); } :host([has-feedback-for='error']) { diff --git a/resources/js/bootstrap/cp-app.ts b/resources/js/bootstrap/cp-app.ts index 990b2d49f2c..4a919eb0f2f 100644 --- a/resources/js/bootstrap/cp-app.ts +++ b/resources/js/bootstrap/cp-app.ts @@ -17,9 +17,11 @@ import AssetIndexes from '@/modules/utilities/components/asset-indexes/AssetInde import SystemMessages from '@/modules/utilities/components/system-messages/SystemMessages.vue'; import CpLink from '@/common/components/CpLink.vue'; import {cpComponentRegistry} from './components'; +import {elementDetailsTabRegistry} from './element-details-tabs'; import {registerFormComponents} from '@/modules/forms/register'; import {registerWidgetComponents} from '@/modules/dashboard/register'; import {registerActivityComponents} from '@/modules/activity/register'; +import {registerWorkflowComponents} from '@/modules/workflows/register'; export const config = ConfigService.getInstance(); export const queue = QueueService.getInstance(); @@ -27,6 +29,7 @@ export const queue = QueueService.getInstance(); registerFormComponents(cpComponentRegistry); registerWidgetComponents(cpComponentRegistry); registerActivityComponents(cpComponentRegistry); +registerWorkflowComponents(cpComponentRegistry, elementDetailsTabRegistry); export function installCpApp(app: App): void { app.config.compilerOptions.isCustomElement = (tag) => tag.includes('-'); diff --git a/resources/js/bootstrap/element-details-tabs.ts b/resources/js/bootstrap/element-details-tabs.ts index 344c0844056..5fd3f82c084 100644 --- a/resources/js/bootstrap/element-details-tabs.ts +++ b/resources/js/bootstrap/element-details-tabs.ts @@ -1,6 +1,21 @@ import {shallowReactive} from 'vue'; import type {Component} from 'vue'; -import type {ElementEditPayload} from '@/modules/elements/composables/useElementEditor'; +import type { + ElementEditPayload, + ElementEditPayloadUpdater, +} from '@/modules/elements/composables/useElementEditor'; + +export interface ElementDetailsTabContext { + payload: ElementEditPayload; + active: boolean; + refreshToken: number; + updatePayload: ElementEditPayloadUpdater; +} + +export interface ElementDetailsTabStatus { + label: string; + indicator: string; +} export interface ElementDetailsTabDescriptor { /** A plugin-scoped identifier that remains stable across registrations. */ @@ -8,8 +23,12 @@ export interface ElementDetailsTabDescriptor { label: string; icon: string; component: Component; + /** Optional controls rendered at the end of the panel header. */ + headerActionsComponent?: Component; order?: number; visible?: (payload: ElementEditPayload) => boolean; + status?: (payload: ElementEditPayload) => ElementDetailsTabStatus | null; + props?: (context: ElementDetailsTabContext) => Record; } export interface ElementDetailsTabRegistry { diff --git a/resources/js/common/components/Callout.vue b/resources/js/common/components/Callout.vue index defa9bf14a1..923add0bfcf 100644 --- a/resources/js/common/components/Callout.vue +++ b/resources/js/common/components/Callout.vue @@ -32,7 +32,6 @@ padding: var(--c-callout-padding, var(--c-spacing-md)); border: 1px solid var(--c-callout-border-color); color: var(--c-callout-text); - border-radius: var(--c-callout-radius); } .callout--danger { diff --git a/resources/js/common/components/FormActionButtons.vue b/resources/js/common/components/FormActionButtons.vue index f5d6c2de75a..cfdc563f85d 100644 --- a/resources/js/common/components/FormActionButtons.vue +++ b/resources/js/common/components/FormActionButtons.vue @@ -36,9 +36,9 @@ type="button" :variant="button.variant ?? ButtonVariant.Solid" :loading="submitter.isSubmitting(button.label)" - :disabled="form.processing || (button.disabled && !button.disabledReason)" - :aria-disabled=" - button.disabled && button.disabledReason ? 'true' : undefined + :disabled="form.processing || button.disabled" + :focusable-when-disabled=" + button.disabled && button.disabledReason ? true : undefined " @click="onClick(button, $event)" > @@ -57,10 +57,3 @@ - - diff --git a/resources/js/common/components/FormActions.test.ts b/resources/js/common/components/FormActions.test.ts index 6aa65b7235f..95256c6490b 100644 --- a/resources/js/common/components/FormActions.test.ts +++ b/resources/js/common/components/FormActions.test.ts @@ -11,7 +11,7 @@ describe('FormActions', () => { container?.remove(); }); - it('keeps an unavailable additional action focusable with an explanation', () => { + it('keeps an unavailable additional action focusable with an explanation', async () => { const onClick = vi.fn(); container = document.createElement('div'); document.body.append(container); @@ -44,10 +44,11 @@ describe('FormActions', () => { container.querySelector( 'craft-tooltip' )!; + await applyButton.updateComplete; - expect(applyButton.disabled).toBe(false); - expect(applyButton.getAttribute('aria-disabled')).toBe('true'); - expect(applyButton.id).toBe('disabled-form-action-0'); + expect(applyButton.disabled).toBe(true); + expect(applyButton.tabIndex).toBe(0); + expect(applyButton.id).not.toBe(''); expect(tooltip.for).toBe(applyButton.id); expect(tooltip.textContent).toContain( 'This draft must be approved before it can be applied.' @@ -57,4 +58,33 @@ describe('FormActions', () => { expect(onClick).not.toHaveBeenCalled(); }); + + it('hides save controls without hiding non-save actions', () => { + container = document.createElement('div'); + document.body.append(container); + app = createApp({ + render: () => + h(FormActions, { + form: { + processing: false, + recentlySuccessful: false, + hasErrors: false, + }, + saveDisabled: true, + actionItems: [{label: 'Save and continue editing'}], + additionalActions: [{label: 'Duplicate'}], + additionalButtons: [{label: 'View'}], + }), + }); + app.config.compilerOptions.isCustomElement = (tag) => tag.includes('-'); + app.mount(container); + + expect(container.querySelector('[type="submit"]')).toBeNull(); + expect(container.textContent).toContain('View'); + expect( + [...container.querySelectorAll('craft-action-item')].map((item) => + item.textContent?.trim() + ) + ).toContain('Duplicate'); + }); }); diff --git a/resources/js/common/components/FormActions.vue b/resources/js/common/components/FormActions.vue index bd17339c348..e4392e7224a 100644 --- a/resources/js/common/components/FormActions.vue +++ b/resources/js/common/components/FormActions.vue @@ -18,6 +18,7 @@ /** Overrides the submit button's text (e.g. "Save draft"). */ submitLabel?: string; readOnly?: boolean; + saveDisabled?: boolean; }>(); defineSlots<{ @@ -30,7 +31,7 @@