first commit

This commit is contained in:
2024-07-15 12:33:27 +02:00
commit ce50ae282b
22084 changed files with 2623791 additions and 0 deletions

View File

@@ -0,0 +1,205 @@
/*
* DO NOT EDIT THIS FILE.
* See the following change record for more information,
* https://www.drupal.org/node/3084859
* @preserve
*/
/**
* @file
* Styling for the Workspaces off-canvas user interface.
*/
#drupal-off-canvas-wrapper.workspaces-dialog {
padding-bottom: calc(var(--off-canvas-padding) / 2);
}
@media (min-width: 47.9375rem) {
#drupal-off-canvas-wrapper.workspaces-dialog .ui-dialog-content > div {
display: flex;
align-items: flex-end;
width: 100%;
height: 100%;
}
}
@media (max-width: 47.9375rem) {
#drupal-off-canvas-wrapper.workspaces-dialog + .ui-dialog .ui-dialog-content {
max-height: unset !important; /* Override the max-height added by JS. */
}
}
/**
* The Workspace UI hides the titlebar, but we need to show and correctly
* position the close button that is nested within it.
*/
#drupal-off-canvas-wrapper.workspaces-dialog .ui-dialog-titlebar {
all: revert;
}
#drupal-off-canvas-wrapper.workspaces-dialog .ui-dialog-titlebar::before {
content: none;
}
#drupal-off-canvas-wrapper.workspaces-dialog .ui-dialog-titlebar .ui-dialog-title {
display: none;
}
#drupal-off-canvas-wrapper.workspaces-dialog .ui-dialog-titlebar .ui-dialog-titlebar-close {
inset-block-start: 1em;
inset-inline-end: 1em;
z-index: 1;
transform: none;
}
#drupal-off-canvas-wrapper.workspaces-dialog .active-workspace {
padding: 0 var(--off-canvas-padding);
}
@media (min-width: 47.9375rem) {
#drupal-off-canvas-wrapper.workspaces-dialog .active-workspace {
display: flex;
flex-direction: column;
flex-basis: 12.5rem;
flex-grow: 2;
align-self: stretch;
order: 1;
padding: var(--off-canvas-padding) var(--off-canvas-padding) 0;
}
}
#drupal-off-canvas-wrapper.workspaces-dialog .active-workspace__title {
font-size: 0.8125rem;
font-weight: bold;
}
#drupal-off-canvas-wrapper.workspaces-dialog .active-workspace__label {
position: relative; /* Anchor icon pseudo-element. */
padding: 1.125rem 3.125rem 0;
color: #fff;
font-size: 1.125rem;
font-weight: bold;
line-height: 1.2;
}
#drupal-off-canvas-wrapper.workspaces-dialog .active-workspace__label::before {
position: absolute;
inset-inline-start: 0;
display: block;
width: 1.25rem;
height: 1.25rem;
content: "";
background: url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' width='40' height='40' viewBox='0 0 40 40'%3e %3cpath fill='%23F0A100' fill-rule='evenodd' d='M38,0 L2.002,0 C0.896716337,-1.37884559e-07 0.000552089742,0.895716475 0,2.001 L0,38.003 C0,39.105 0.899,40.003 2,40.003 L38,40.003 C39.103,40.003 40,39.103 40,38.003 L40,2.001 C40.000531,1.47031248 39.7900198,0.961193334 39.4148607,0.585846626 C39.0397016,0.210499918 38.5306877,-0.000265742306 38,0 Z M34.003,4 C35.105,4 36.003,4.899 36.003,6 C36.003,7.102 35.103,7.998 34.003,7.998 C32.9235903,7.96385326 32.0662376,7.07894966 32.0662376,5.999 C32.0662376,4.91905034 32.9235903,4.03414674 34.003,4 Z M26.003,4 C27.105,4 28.002,4.899 28.002,6 C28.002,7.102 27.102,7.998 26.002,7.998 C24.9225903,7.96385326 24.0652376,7.07894966 24.0652376,5.999 C24.0652376,4.91905034 24.9225903,4.03414674 26.002,4 L26.003,4 Z M18.002,4 C19.104,4 20.002,4.899 20.002,6 C20.002,7.102 19.102,7.998 18.002,7.998 C16.899,7.998 16.002,7.1 16.002,5.999 C16.0025521,4.89482104 16.8978209,3.99999986 18.002,4 Z M36.002,36.002 L4,36.002 L4,12.001 L36.002,12.001 L36.002,36.002 Z M8.805,32.002 L15.196,32.002 C15.4092125,32.0030667 15.6140295,31.9189775 15.764983,31.7683995 C15.9159365,31.6178215 16.0005357,31.4132145 16,31.2 L16,16.805 C16.0005341,16.5919596 15.916072,16.3875055 15.7653354,16.2369566 C15.6145988,16.0864077 15.4100395,16.0022004 15.197,16.003 L8.794,16.003 C8.581215,16.0027342 8.37706868,16.087145 8.22660684,16.2376068 C8.07614501,16.3880687 7.99173418,16.592215 7.992,16.805 L7.992,31.208 C7.99966319,31.6507223 8.36222028,32.0048063 8.805,32.002 Z M20.803,24.002 L31.206,24.002 C31.4190404,24.0025341 31.6234945,23.918072 31.7740434,23.7673354 C31.9245923,23.6165988 32.0087996,23.4120395 32.008,23.199 L32.008,16.797 C32.0085328,16.5841335 31.9242078,16.3798319 31.7736879,16.2293121 C31.6231681,16.0787922 31.4188665,15.9944672 31.206,15.995 L20.803,15.995 C20.5901335,15.9944672 20.3858319,16.0787922 20.2353121,16.2293121 C20.0847922,16.3798319 20.0004672,16.5841335 20.001,16.797 L20.001,23.199 C20.001,23.646 20.356,24.001 20.803,24.001 L20.803,24.002 Z M20.803,32.002 L31.206,32.002 C31.4188665,32.0025328 31.6231681,31.9182078 31.7736879,31.7676879 C31.9242078,31.6171681 32.0085328,31.4128665 32.008,31.2 L32.008,28.797 C32.0085328,28.5841335 31.9242078,28.3798319 31.7736879,28.2293121 C31.6231681,28.0787922 31.4188665,27.9944672 31.206,27.995 L20.803,27.995 C20.5901335,27.9944672 20.3858319,28.0787922 20.2353121,28.2293121 C20.0847922,28.3798319 20.0004672,28.5841335 20.001,28.797 L20.001,31.2 C20.001,31.647 20.356,32.002 20.803,32.002 Z'/%3e%3c/svg%3e") center center no-repeat;
background-size: contain;
}
@media (min-width: 47.9375rem) {
#drupal-off-canvas-wrapper.workspaces-dialog .active-workspace__label::before {
width: 2.5rem;
height: 2.5rem;
}
}
/* This is the "Manage workspace" link that appears when you're on a non-default workspace. */
#drupal-off-canvas-wrapper.workspaces-dialog .active-workspace__manage {
display: block;
font-size: 0.8125rem;
}
/* This is the link to "View all workspaces". */
#drupal-off-canvas-wrapper.workspaces-dialog .all-workspaces {
display: inline-block;
padding: var(--off-canvas-padding);
font-size: 0.875rem;
}
@media (min-width: 47.9375rem) {
#drupal-off-canvas-wrapper.workspaces-dialog .all-workspaces {
grid-row: 1;
grid-column: 2;
justify-self: end;
padding: 0;
}
}
#drupal-off-canvas-wrapper.workspaces-dialog .workspaces > h3 {
margin-top: 0;
}
#drupal-off-canvas-wrapper.workspaces-dialog .workspaces ul {
display: flex;
flex-direction: column;
grid-row: 2;
grid-column: 1 / -1;
margin: 0;
padding: 0;
list-style: none;
gap: 2px;
}
@media (min-width: 47.9375rem) {
#drupal-off-canvas-wrapper.workspaces-dialog .workspaces ul {
flex-direction: row;
}
}
#drupal-off-canvas-wrapper.workspaces-dialog .workspaces li {
flex: 1;
}
@media (min-width: 47.9375rem) {
#drupal-off-canvas-wrapper.workspaces-dialog .workspaces {
display: grid;
flex-grow: 8;
grid-template-columns: 1fr 1fr;
}
}
/* This is the link to the workspace. */
#drupal-off-canvas-wrapper.workspaces-dialog .workspaces__item {
position: relative;
display: block;
min-height: 4.6875rem;
padding-block-start: var(--off-canvas-padding);
padding-inline-start: 3.125rem;
color: var(--off-canvas-text-color);
outline-offset: -2px; /* Ensure focus outline doesn't overflow. */
background-color: var(--off-canvas-background-color-light);
font-size: 0.875rem;
font-weight: bold;
}
#drupal-off-canvas-wrapper.workspaces-dialog .workspaces__item:hover,
#drupal-off-canvas-wrapper.workspaces-dialog .workspaces__item:focus {
background-color: #666;
}
#drupal-off-canvas-wrapper.workspaces-dialog .workspaces__item::before {
position: absolute;
inset-inline-start: var(--off-canvas-padding);
display: block;
width: 1.25rem;
height: 1.25rem;
content: "";
background: url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' width='40' height='40' viewBox='0 0 40 40'%3e %3cpath fill='%23F0A100' fill-rule='evenodd' d='M38,0 L2.002,0 C0.896716337,-1.37884559e-07 0.000552089742,0.895716475 0,2.001 L0,38.003 C0,39.105 0.899,40.003 2,40.003 L38,40.003 C39.103,40.003 40,39.103 40,38.003 L40,2.001 C40.000531,1.47031248 39.7900198,0.961193334 39.4148607,0.585846626 C39.0397016,0.210499918 38.5306877,-0.000265742306 38,0 Z M34.003,4 C35.105,4 36.003,4.899 36.003,6 C36.003,7.102 35.103,7.998 34.003,7.998 C32.9235903,7.96385326 32.0662376,7.07894966 32.0662376,5.999 C32.0662376,4.91905034 32.9235903,4.03414674 34.003,4 Z M26.003,4 C27.105,4 28.002,4.899 28.002,6 C28.002,7.102 27.102,7.998 26.002,7.998 C24.9225903,7.96385326 24.0652376,7.07894966 24.0652376,5.999 C24.0652376,4.91905034 24.9225903,4.03414674 26.002,4 L26.003,4 Z M18.002,4 C19.104,4 20.002,4.899 20.002,6 C20.002,7.102 19.102,7.998 18.002,7.998 C16.899,7.998 16.002,7.1 16.002,5.999 C16.0025521,4.89482104 16.8978209,3.99999986 18.002,4 Z M36.002,36.002 L4,36.002 L4,12.001 L36.002,12.001 L36.002,36.002 Z M8.805,32.002 L15.196,32.002 C15.4092125,32.0030667 15.6140295,31.9189775 15.764983,31.7683995 C15.9159365,31.6178215 16.0005357,31.4132145 16,31.2 L16,16.805 C16.0005341,16.5919596 15.916072,16.3875055 15.7653354,16.2369566 C15.6145988,16.0864077 15.4100395,16.0022004 15.197,16.003 L8.794,16.003 C8.581215,16.0027342 8.37706868,16.087145 8.22660684,16.2376068 C8.07614501,16.3880687 7.99173418,16.592215 7.992,16.805 L7.992,31.208 C7.99966319,31.6507223 8.36222028,32.0048063 8.805,32.002 Z M20.803,24.002 L31.206,24.002 C31.4190404,24.0025341 31.6234945,23.918072 31.7740434,23.7673354 C31.9245923,23.6165988 32.0087996,23.4120395 32.008,23.199 L32.008,16.797 C32.0085328,16.5841335 31.9242078,16.3798319 31.7736879,16.2293121 C31.6231681,16.0787922 31.4188665,15.9944672 31.206,15.995 L20.803,15.995 C20.5901335,15.9944672 20.3858319,16.0787922 20.2353121,16.2293121 C20.0847922,16.3798319 20.0004672,16.5841335 20.001,16.797 L20.001,23.199 C20.001,23.646 20.356,24.001 20.803,24.001 L20.803,24.002 Z M20.803,32.002 L31.206,32.002 C31.4188665,32.0025328 31.6231681,31.9182078 31.7736879,31.7676879 C31.9242078,31.6171681 32.0085328,31.4128665 32.008,31.2 L32.008,28.797 C32.0085328,28.5841335 31.9242078,28.3798319 31.7736879,28.2293121 C31.6231681,28.0787922 31.4188665,27.9944672 31.206,27.995 L20.803,27.995 C20.5901335,27.9944672 20.3858319,28.0787922 20.2353121,28.2293121 C20.0847922,28.3798319 20.0004672,28.5841335 20.001,28.797 L20.001,31.2 C20.001,31.647 20.356,32.002 20.803,32.002 Z'/%3e%3c/svg%3e") center center no-repeat;
background-size: 100% auto;
}
#drupal-off-canvas-wrapper.workspaces-dialog .active-workspace--default .active-workspace__label::before,
#drupal-off-canvas-wrapper.workspaces-dialog .workspaces__item--default::before {
background-image: url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' width='20' height='20' viewBox='0 0 20 20'%3e %3cpath fill='%2381C071' fill-rule='evenodd' d='M19,0 L1,0 C0.449,0 0,0.448 0,1 L0,19 C0,19.552 0.45,20 1,20 L19,20 C19.552,20 20,19.55 20,19 L20,1 C20,0.44771525 19.5522847,3.38176876e-17 19,0 Z M17.001,2 C17.553,2 18.001,2.45 18.001,3 C18.001,3.55 17.551,3.999 17.001,3.999 C16.451,3.999 16.001,3.549 16.001,2.999 C16.001,2.44671525 16.4487153,1.999 17.001,1.999 L17.001,2 Z M13.001,2 C13.552,2 14.001,2.45 14.001,3 C14.001,3.55 13.551,3.999 13.001,3.999 C12.4487153,3.999 12.001,3.55128475 12.001,2.999 C12.001,2.44671525 12.4487153,1.999 13.001,1.999 L13.001,2 Z M9.001,2 C9.552,2 10.001,2.45 10.001,3 C10.001,3.55 9.551,3.999 9.001,3.999 C8.44871525,3.999 8.001,3.55128475 8.001,2.999 C8.001,2.44671525 8.44871525,1.999 9.001,1.999 L9.001,2 Z M18.001,18 L2,18 L2,6 L18.001,6 L18.001,18 Z M4.402,16 L7.598,16 C7.70460623,16.0005334 7.80701477,15.9584887 7.88249152,15.8831997 C7.95796827,15.8079107 8.00026785,15.7056072 8,15.599 L8,8.402 C8.00026565,8.29574025 7.95824022,8.19374159 7.88319685,8.11851062 C7.80815349,8.04327965 7.70626008,8.00099967 7.6,8.001 L4.396,8.001 C4.28956674,8.00073358 4.18741595,8.04289612 4.11215603,8.11815603 C4.03689612,8.19341595 3.99473358,8.29556674 3.995,8.402 L3.995,15.603 C3.999,15.823 4.177,16 4.401,16 L4.402,16 Z M10.402,12 L15.603,12 C15.7094333,12.0002664 15.811584,11.9581039 15.886844,11.882844 C15.9621039,11.807584 16.0042664,11.7054333 16.004,11.599 L16.004,8.398 C16.0042664,8.29156674 15.9621039,8.18941595 15.886844,8.11415603 C15.811584,8.03889612 15.7094333,7.99673358 15.603,7.997 L10.402,7.997 C10.2957402,7.99673435 10.1937416,8.03875978 10.1185106,8.11380315 C10.0432796,8.18884651 10.0009997,8.29073992 10.001,8.397 L10.001,11.6 C10.001,11.824 10.178,12 10.401,12 L10.402,12 Z M10.402,16 L15.603,16 C15.7094333,16.0002664 15.811584,15.9581039 15.886844,15.882844 C15.9621039,15.807584 16.0042664,15.7054333 16.004,15.599 L16.004,14.398 C16.0042664,14.2915667 15.9621039,14.189416 15.886844,14.114156 C15.811584,14.0388961 15.7094333,13.9967336 15.603,13.997 L10.402,13.997 C10.2957402,13.9967343 10.1937416,14.0387598 10.1185106,14.1138031 C10.0432796,14.1888465 10.0009997,14.2907399 10.001,14.397 L10.001,15.6 C10.001,15.824 10.178,16 10.401,16 L10.402,16 Z'/%3e%3c/svg%3e"); /* Green icon. */
}
#drupal-off-canvas-wrapper.workspaces-dialog .active-workspace__actions .button {
margin: 0.625rem 0 0;
}
@media (max-width: 47.9375rem) {
#drupal-off-canvas-wrapper.workspaces-dialog {
height: 100% !important;
}
}

View File

@@ -0,0 +1,187 @@
/**
* @file
* Styling for the Workspaces off-canvas user interface.
*/
@custom-media --workspace-layout-small (max-width: 767px);
@custom-media --workspace-layout-large (min-width: 767px);
#drupal-off-canvas-wrapper.workspaces-dialog {
padding-bottom: calc(var(--off-canvas-padding) / 2);
& .ui-dialog-content > div {
@media (--workspace-layout-large) {
display: flex;
align-items: flex-end;
width: 100%;
height: 100%;
}
}
& + .ui-dialog .ui-dialog-content {
@media (--workspace-layout-small) {
max-height: unset !important; /* Override the max-height added by JS. */
}
}
/**
* The Workspace UI hides the titlebar, but we need to show and correctly
* position the close button that is nested within it.
*/
& .ui-dialog-titlebar {
all: revert;
&::before {
content: none;
}
& .ui-dialog-title {
display: none;
}
& .ui-dialog-titlebar-close {
inset-block-start: 1em;
inset-inline-end: 1em;
z-index: 1;
transform: none;
}
}
& .active-workspace {
padding: 0 var(--off-canvas-padding);
@media (--workspace-layout-large) {
display: flex;
flex-direction: column;
flex-basis: 200px;
flex-grow: 2;
align-self: stretch;
order: 1;
padding: var(--off-canvas-padding) var(--off-canvas-padding) 0;
}
}
& .active-workspace__title {
font-size: 13px;
font-weight: bold;
}
& .active-workspace__label {
position: relative; /* Anchor icon pseudo-element. */
padding: 18px 50px 0;
color: #fff;
font-size: 18px;
font-weight: bold;
line-height: 1.2;
&::before {
position: absolute;
inset-inline-start: 0;
display: block;
width: 20px;
height: 20px;
content: "";
background: url("../icons/f0a100/ws_icon.svg") center center no-repeat;
background-size: contain;
@media (--workspace-layout-large) {
width: 40px;
height: 40px;
}
}
}
/* This is the "Manage workspace" link that appears when you're on a non-default workspace. */
& .active-workspace__manage {
display: block;
font-size: 13px;
}
/* This is the link to "View all workspaces". */
& .all-workspaces {
display: inline-block;
padding: var(--off-canvas-padding);
font-size: 14px;
@media (--workspace-layout-large) {
grid-row: 1;
grid-column: 2;
justify-self: end;
padding: 0;
}
}
& .workspaces {
& > h3 {
margin-top: 0;
}
& ul {
display: flex;
flex-direction: column;
grid-row: 2;
grid-column: 1 / -1;
margin: 0;
padding: 0;
list-style: none;
gap: 2px;
@media (--workspace-layout-large) {
flex-direction: row;
}
}
& li {
flex: 1;
}
@media (--workspace-layout-large) {
display: grid;
flex-grow: 8;
grid-template-columns: 1fr 1fr;
}
}
/* This is the link to the workspace. */
& .workspaces__item {
position: relative;
display: block;
min-height: 75px;
padding-block-start: var(--off-canvas-padding);
padding-inline-start: 50px;
color: var(--off-canvas-text-color);
outline-offset: -2px; /* Ensure focus outline doesn't overflow. */
background-color: var(--off-canvas-background-color-light);
font-size: 14px;
font-weight: bold;
&:hover,
&:focus {
background-color: #666;
}
&::before {
position: absolute;
inset-inline-start: var(--off-canvas-padding);
display: block;
width: 20px;
height: 20px;
content: "";
background: url("../icons/f0a100/ws_icon.svg") center center no-repeat;
background-size: 100% auto;
}
}
& .active-workspace--default .active-workspace__label::before,
& .workspaces__item--default::before {
background-image: url("../icons/81c071/ws_icon.svg"); /* Green icon. */
}
& .active-workspace__actions .button {
margin: 10px 0 0;
}
@media (--workspace-layout-small) {
height: 100% !important;
}
}

View File

@@ -0,0 +1,17 @@
/*
* DO NOT EDIT THIS FILE.
* See the following change record for more information,
* https://www.drupal.org/node/3084859
* @preserve
*/
/**
* @file
* Styling for the Workspaces overview table.
*/
/** @todo Move to Claro theme before Workspaces is marked stable. */
tr.active-workspace {
background-color: #ebeae4;
}

View File

@@ -0,0 +1,9 @@
/**
* @file
* Styling for the Workspaces overview table.
*/
/** @todo Move to Claro theme before Workspaces is marked stable. */
tr.active-workspace {
background-color: #ebeae4;
}

View File

@@ -0,0 +1,96 @@
/*
* DO NOT EDIT THIS FILE.
* See the following change record for more information,
* https://www.drupal.org/node/3084859
* @preserve
*/
/**
* @file
* Styling for Workspaces module's toolbar tab.
*/
/* Toolbar tab */
.toolbar .toolbar-bar .workspaces-toolbar-tab {
color: #000;
background-color: #e09600;
}
.toolbar .toolbar-bar .workspaces-toolbar-tab--is-default {
background-color: #81c071;
}
.toolbar-oriented .toolbar-bar .workspaces-toolbar-tab {
float: right; /* LTR */
/**
* Chromium and Webkit do not yet support flow relative logical properties,
* such as float: inline-end. However, PostCSS Logical does not compile this
* value, so we accommodate by not using these.
*
* @see https://caniuse.com/mdn-css_properties_clear_flow_relative_values
* @see https://github.com/csstools/postcss-plugins/issues/632
*/
}
[dir="rtl"] .toolbar-oriented .toolbar-bar .workspaces-toolbar-tab {
float: left;
}
@media (min-width: 16.5rem) {
.toolbar:not(.toolbar-oriented) .toolbar-bar .workspaces-toolbar-tab {
float: right; /* LTR */
/**
* Chromium and Webkit do not yet support flow relative logical properties,
* such as float: inline-end. However, PostCSS Logical does not compile this
* value, so we accommodate by not using these.
*
* @see https://caniuse.com/mdn-css_properties_clear_flow_relative_values
* @see https://github.com/csstools/postcss-plugins/issues/632
*/
}
[dir="rtl"] .toolbar:not(.toolbar-oriented) .toolbar-bar .workspaces-toolbar-tab {
float: left;
}
}
/* Link within the toolbar tab. */
.toolbar .toolbar-bar .workspaces-toolbar-tab .toolbar-item {
width: 100%;
margin: 0;
text-align: start;
color: inherit;
}
.toolbar-oriented :is(.toolbar .toolbar-bar .workspaces-toolbar-tab .toolbar-item) {
width: auto;
text-align: initial;
}
.toolbar .toolbar-icon-workspace::before {
background-image: url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' width='16' height='16' viewBox='0 0 16 16'%3e %3cpath d='M14,12 L16,12 L16,0 L4,0 L4,2 L14,2 L14,12 Z M0,4 L12,4 L12,16 L0,16 L0,4 Z'/%3e%3c/svg%3e");
}
@media all and (max-width: 47.875rem) {
.toolbar-oriented .toolbar-bar .workspaces-toolbar-tab .toolbar-icon-workspace {
width: auto;
max-width: 8em;
}
.toolbar .toolbar-bar .workspaces-toolbar-tab .toolbar-icon-workspace {
overflow: hidden;
padding-inline: 2.75em 1.3333em;
white-space: nowrap;
text-indent: 0;
text-overflow: ellipsis;
}
.toolbar .toolbar-bar .workspaces-toolbar-tab .toolbar-icon-workspace::before {
inset-inline-start: 0.6667em;
width: 1.25rem;
background-size: 100% auto;
}
}

View File

@@ -0,0 +1,84 @@
/**
* @file
* Styling for Workspaces module's toolbar tab.
*/
/* Toolbar tab */
.toolbar .toolbar-bar .workspaces-toolbar-tab {
color: #000;
background-color: #e09600;
}
.toolbar .toolbar-bar .workspaces-toolbar-tab--is-default {
background-color: #81c071;
}
.toolbar-oriented .toolbar-bar .workspaces-toolbar-tab {
float: right; /* LTR */
/**
* Chromium and Webkit do not yet support flow relative logical properties,
* such as float: inline-end. However, PostCSS Logical does not compile this
* value, so we accommodate by not using these.
*
* @see https://caniuse.com/mdn-css_properties_clear_flow_relative_values
* @see https://github.com/csstools/postcss-plugins/issues/632
*/
&:dir(rtl) {
float: left;
}
}
@media (min-width: 264px) {
.toolbar:not(.toolbar-oriented) .toolbar-bar .workspaces-toolbar-tab {
float: right; /* LTR */
/**
* Chromium and Webkit do not yet support flow relative logical properties,
* such as float: inline-end. However, PostCSS Logical does not compile this
* value, so we accommodate by not using these.
*
* @see https://caniuse.com/mdn-css_properties_clear_flow_relative_values
* @see https://github.com/csstools/postcss-plugins/issues/632
*/
&:dir(rtl) {
float: left;
}
}
}
/* Link within the toolbar tab. */
.toolbar .toolbar-bar .workspaces-toolbar-tab .toolbar-item {
width: 100%;
margin: 0;
text-align: start;
color: inherit;
@nest .toolbar-oriented & {
width: auto;
text-align: initial;
}
}
.toolbar .toolbar-icon-workspace::before {
background-image: url("../icons/000000/workspaces.svg");
}
@media all and (max-width: 766px) {
.toolbar-oriented .toolbar-bar .workspaces-toolbar-tab .toolbar-icon-workspace {
width: auto;
max-width: 8em;
}
.toolbar .toolbar-bar .workspaces-toolbar-tab .toolbar-icon-workspace {
overflow: hidden;
padding-inline: 2.75em 1.3333em;
white-space: nowrap;
text-indent: 0;
text-overflow: ellipsis;
}
.toolbar .toolbar-bar .workspaces-toolbar-tab .toolbar-icon-workspace::before {
inset-inline-start: 0.6667em;
width: 20px;
background-size: 100% auto;
}
}

View File

@@ -0,0 +1,3 @@
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 16 16">
<path d="M14,12 L16,12 L16,0 L4,0 L4,2 L14,2 L14,12 Z M0,4 L12,4 L12,16 L0,16 L0,4 Z"/>
</svg>

After

Width:  |  Height:  |  Size: 181 B

View File

@@ -0,0 +1,3 @@
<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 20 20">
<path fill="#81C071" fill-rule="evenodd" d="M19,0 L1,0 C0.449,0 0,0.448 0,1 L0,19 C0,19.552 0.45,20 1,20 L19,20 C19.552,20 20,19.55 20,19 L20,1 C20,0.44771525 19.5522847,3.38176876e-17 19,0 Z M17.001,2 C17.553,2 18.001,2.45 18.001,3 C18.001,3.55 17.551,3.999 17.001,3.999 C16.451,3.999 16.001,3.549 16.001,2.999 C16.001,2.44671525 16.4487153,1.999 17.001,1.999 L17.001,2 Z M13.001,2 C13.552,2 14.001,2.45 14.001,3 C14.001,3.55 13.551,3.999 13.001,3.999 C12.4487153,3.999 12.001,3.55128475 12.001,2.999 C12.001,2.44671525 12.4487153,1.999 13.001,1.999 L13.001,2 Z M9.001,2 C9.552,2 10.001,2.45 10.001,3 C10.001,3.55 9.551,3.999 9.001,3.999 C8.44871525,3.999 8.001,3.55128475 8.001,2.999 C8.001,2.44671525 8.44871525,1.999 9.001,1.999 L9.001,2 Z M18.001,18 L2,18 L2,6 L18.001,6 L18.001,18 Z M4.402,16 L7.598,16 C7.70460623,16.0005334 7.80701477,15.9584887 7.88249152,15.8831997 C7.95796827,15.8079107 8.00026785,15.7056072 8,15.599 L8,8.402 C8.00026565,8.29574025 7.95824022,8.19374159 7.88319685,8.11851062 C7.80815349,8.04327965 7.70626008,8.00099967 7.6,8.001 L4.396,8.001 C4.28956674,8.00073358 4.18741595,8.04289612 4.11215603,8.11815603 C4.03689612,8.19341595 3.99473358,8.29556674 3.995,8.402 L3.995,15.603 C3.999,15.823 4.177,16 4.401,16 L4.402,16 Z M10.402,12 L15.603,12 C15.7094333,12.0002664 15.811584,11.9581039 15.886844,11.882844 C15.9621039,11.807584 16.0042664,11.7054333 16.004,11.599 L16.004,8.398 C16.0042664,8.29156674 15.9621039,8.18941595 15.886844,8.11415603 C15.811584,8.03889612 15.7094333,7.99673358 15.603,7.997 L10.402,7.997 C10.2957402,7.99673435 10.1937416,8.03875978 10.1185106,8.11380315 C10.0432796,8.18884651 10.0009997,8.29073992 10.001,8.397 L10.001,11.6 C10.001,11.824 10.178,12 10.401,12 L10.402,12 Z M10.402,16 L15.603,16 C15.7094333,16.0002664 15.811584,15.9581039 15.886844,15.882844 C15.9621039,15.807584 16.0042664,15.7054333 16.004,15.599 L16.004,14.398 C16.0042664,14.2915667 15.9621039,14.189416 15.886844,14.114156 C15.811584,14.0388961 15.7094333,13.9967336 15.603,13.997 L10.402,13.997 C10.2957402,13.9967343 10.1937416,14.0387598 10.1185106,14.1138031 C10.0432796,14.1888465 10.0009997,14.2907399 10.001,14.397 L10.001,15.6 C10.001,15.824 10.178,16 10.401,16 L10.402,16 Z"/>
</svg>

After

Width:  |  Height:  |  Size: 2.3 KiB

View File

@@ -0,0 +1,3 @@
<svg xmlns="http://www.w3.org/2000/svg" width="40" height="40" viewBox="0 0 40 40">
<path fill="#F0A100" fill-rule="evenodd" d="M38,0 L2.002,0 C0.896716337,-1.37884559e-07 0.000552089742,0.895716475 0,2.001 L0,38.003 C0,39.105 0.899,40.003 2,40.003 L38,40.003 C39.103,40.003 40,39.103 40,38.003 L40,2.001 C40.000531,1.47031248 39.7900198,0.961193334 39.4148607,0.585846626 C39.0397016,0.210499918 38.5306877,-0.000265742306 38,0 Z M34.003,4 C35.105,4 36.003,4.899 36.003,6 C36.003,7.102 35.103,7.998 34.003,7.998 C32.9235903,7.96385326 32.0662376,7.07894966 32.0662376,5.999 C32.0662376,4.91905034 32.9235903,4.03414674 34.003,4 Z M26.003,4 C27.105,4 28.002,4.899 28.002,6 C28.002,7.102 27.102,7.998 26.002,7.998 C24.9225903,7.96385326 24.0652376,7.07894966 24.0652376,5.999 C24.0652376,4.91905034 24.9225903,4.03414674 26.002,4 L26.003,4 Z M18.002,4 C19.104,4 20.002,4.899 20.002,6 C20.002,7.102 19.102,7.998 18.002,7.998 C16.899,7.998 16.002,7.1 16.002,5.999 C16.0025521,4.89482104 16.8978209,3.99999986 18.002,4 Z M36.002,36.002 L4,36.002 L4,12.001 L36.002,12.001 L36.002,36.002 Z M8.805,32.002 L15.196,32.002 C15.4092125,32.0030667 15.6140295,31.9189775 15.764983,31.7683995 C15.9159365,31.6178215 16.0005357,31.4132145 16,31.2 L16,16.805 C16.0005341,16.5919596 15.916072,16.3875055 15.7653354,16.2369566 C15.6145988,16.0864077 15.4100395,16.0022004 15.197,16.003 L8.794,16.003 C8.581215,16.0027342 8.37706868,16.087145 8.22660684,16.2376068 C8.07614501,16.3880687 7.99173418,16.592215 7.992,16.805 L7.992,31.208 C7.99966319,31.6507223 8.36222028,32.0048063 8.805,32.002 Z M20.803,24.002 L31.206,24.002 C31.4190404,24.0025341 31.6234945,23.918072 31.7740434,23.7673354 C31.9245923,23.6165988 32.0087996,23.4120395 32.008,23.199 L32.008,16.797 C32.0085328,16.5841335 31.9242078,16.3798319 31.7736879,16.2293121 C31.6231681,16.0787922 31.4188665,15.9944672 31.206,15.995 L20.803,15.995 C20.5901335,15.9944672 20.3858319,16.0787922 20.2353121,16.2293121 C20.0847922,16.3798319 20.0004672,16.5841335 20.001,16.797 L20.001,23.199 C20.001,23.646 20.356,24.001 20.803,24.001 L20.803,24.002 Z M20.803,32.002 L31.206,32.002 C31.4188665,32.0025328 31.6231681,31.9182078 31.7736879,31.7676879 C31.9242078,31.6171681 32.0085328,31.4128665 32.008,31.2 L32.008,28.797 C32.0085328,28.5841335 31.9242078,28.3798319 31.7736879,28.2293121 C31.6231681,28.0787922 31.4188665,27.9944672 31.206,27.995 L20.803,27.995 C20.5901335,27.9944672 20.3858319,28.0787922 20.2353121,28.2293121 C20.0847922,28.3798319 20.0004672,28.5841335 20.001,28.797 L20.001,31.2 C20.001,31.647 20.356,32.002 20.803,32.002 Z"/>
</svg>

After

Width:  |  Height:  |  Size: 2.5 KiB

View File

@@ -0,0 +1,50 @@
<?php
namespace Drupal\workspaces\Access;
use Drupal\Core\Access\AccessResult;
use Drupal\Core\Routing\Access\AccessInterface;
use Drupal\workspaces\WorkspaceManagerInterface;
use Symfony\Component\Routing\Route;
/**
* Determines access to routes based on the presence of an active workspace.
*/
class ActiveWorkspaceCheck implements AccessInterface {
/**
* The workspace manager.
*
* @var \Drupal\workspaces\WorkspaceManagerInterface
*/
protected $workspaceManager;
/**
* Constructs a new ActiveWorkspaceCheck.
*
* @param \Drupal\workspaces\WorkspaceManagerInterface $workspace_manager
* The workspace manager.
*/
public function __construct(WorkspaceManagerInterface $workspace_manager) {
$this->workspaceManager = $workspace_manager;
}
/**
* Checks access.
*
* @param \Symfony\Component\Routing\Route $route
* The route to check against.
*
* @return \Drupal\Core\Access\AccessResultInterface
* The access result.
*/
public function access(Route $route) {
if (!$route->hasRequirement('_has_active_workspace')) {
return AccessResult::neutral();
}
$required_value = filter_var($route->getRequirement('_has_active_workspace'), FILTER_VALIDATE_BOOLEAN);
return AccessResult::allowedIf($required_value === $this->workspaceManager->hasActiveWorkspace())->addCacheContexts(['workspace']);
}
}

View File

@@ -0,0 +1,97 @@
<?php
declare(strict_types=1);
namespace Drupal\workspaces\Controller;
use Drupal\Core\Controller\FormController;
use Drupal\Core\DependencyInjection\DependencySerializationTrait;
use Drupal\Core\Form\FormInterface;
use Drupal\Core\Routing\RouteMatchInterface;
use Drupal\Core\StringTranslation\StringTranslationTrait;
use Drupal\Core\TypedData\TypedDataManagerInterface;
use Drupal\workspaces\Plugin\Validation\Constraint\EntityWorkspaceConflictConstraint;
use Drupal\workspaces\WorkspaceInformationInterface;
use Drupal\workspaces\WorkspaceManagerInterface;
use Symfony\Component\HttpFoundation\Request;
/**
* Overrides the entity form controller service for workspaces operations.
*/
class WorkspacesHtmlEntityFormController extends FormController {
use DependencySerializationTrait;
use StringTranslationTrait;
public function __construct(
protected readonly FormController $entityFormController,
protected readonly WorkspaceManagerInterface $workspaceManager,
protected readonly WorkspaceInformationInterface $workspaceInfo,
protected readonly TypedDataManagerInterface $typedDataManager,
) {}
/**
* {@inheritdoc}
*/
public function getContentResult(Request $request, RouteMatchInterface $route_match): array {
$form_arg = $this->getFormArgument($route_match);
$form_object = $this->getFormObject($route_match, $form_arg);
/** @var \Drupal\Core\Entity\EntityInterface $entity */
$entity = $form_object->getEntity();
if ($this->workspaceInfo->isEntitySupported($entity)) {
$active_workspace = $this->workspaceManager->getActiveWorkspace();
// Prepare a minimal render array in case we need to return it.
$build['#cache']['contexts'] = $entity->getCacheContexts();
$build['#cache']['tags'] = $entity->getCacheTags();
$build['#cache']['max-age'] = $entity->getCacheMaxAge();
// Prevent entities from being edited if they're tracked in workspace.
if ($form_object->getOperation() !== 'delete') {
$constraints = array_values(array_filter($entity->getTypedData()->getConstraints(), function ($constraint) {
return $constraint instanceof EntityWorkspaceConflictConstraint;
}));
if (!empty($constraints)) {
$violations = $this->typedDataManager->getValidator()->validate(
$entity->getTypedData(),
$constraints[0]
);
if (count($violations)) {
$build['#markup'] = $violations->get(0)->getMessage();
return $build;
}
}
}
// Prevent entities from being deleted in a workspace if they have a
// published default revision.
if ($form_object->getOperation() === 'delete' && $active_workspace && !$this->workspaceInfo->isEntityDeletable($entity, $active_workspace)) {
$build['#markup'] = $this->t('This @entity_type_label can only be deleted in the Live workspace.', [
'@entity_type_label' => $entity->getEntityType()->getSingularLabel(),
]);
return $build;
}
}
return $this->entityFormController->getContentResult($request, $route_match);
}
/**
* {@inheritdoc}
*/
protected function getFormArgument(RouteMatchInterface $route_match): string {
return $this->entityFormController->getFormArgument($route_match);
}
/**
* {@inheritdoc}
*/
protected function getFormObject(RouteMatchInterface $route_match, $form_arg): FormInterface {
return $this->entityFormController->getFormObject($route_match, $form_arg);
}
}

View File

@@ -0,0 +1,24 @@
<?php
namespace Drupal\workspaces\Entity\Handler;
use Drupal\Core\Entity\EntityInterface;
/**
* Provides a custom workspace handler for block_content entities.
*
* @internal
*/
class BlockContentWorkspaceHandler extends DefaultWorkspaceHandler {
/**
* {@inheritdoc}
*/
public function isEntitySupported(EntityInterface $entity): bool {
// Only reusable blocks can be tracked individually. Non-reusable or inline
// blocks are tracked as part of the entity they are a composite of.
/** @var \Drupal\block_content\BlockContentInterface $entity */
return $entity->isReusable();
}
}

View File

@@ -0,0 +1,31 @@
<?php
namespace Drupal\workspaces\Entity\Handler;
use Drupal\Core\Entity\EntityHandlerInterface;
use Drupal\Core\Entity\EntityInterface;
use Drupal\Core\Entity\EntityTypeInterface;
use Symfony\Component\DependencyInjection\ContainerInterface;
/**
* Common customizations for most entity types.
*
* @internal
*/
class DefaultWorkspaceHandler implements WorkspaceHandlerInterface, EntityHandlerInterface {
/**
* {@inheritdoc}
*/
public static function createInstance(ContainerInterface $container, EntityTypeInterface $entity_type) {
return new static();
}
/**
* {@inheritdoc}
*/
public function isEntitySupported(EntityInterface $entity): bool {
return TRUE;
}
}

View File

@@ -0,0 +1,31 @@
<?php
namespace Drupal\workspaces\Entity\Handler;
use Drupal\Core\Entity\EntityHandlerInterface;
use Drupal\Core\Entity\EntityInterface;
use Drupal\Core\Entity\EntityTypeInterface;
use Symfony\Component\DependencyInjection\ContainerInterface;
/**
* Defines a handler for entity types that are ignored by workspaces.
*
* @internal
*/
class IgnoredWorkspaceHandler implements WorkspaceHandlerInterface, EntityHandlerInterface {
/**
* {@inheritdoc}
*/
public static function createInstance(ContainerInterface $container, EntityTypeInterface $entity_type) {
return new static();
}
/**
* {@inheritdoc}
*/
public function isEntitySupported(EntityInterface $entity): bool {
return FALSE;
}
}

View File

@@ -0,0 +1,30 @@
<?php
namespace Drupal\workspaces\Entity\Handler;
use Drupal\Core\Entity\EntityInterface;
/**
* Defines workspace operations that need to vary by entity type.
*
* @internal
*/
interface WorkspaceHandlerInterface {
/**
* Determines if an entity should be tracked in a workspace.
*
* At the general level, workspace support is determined for the entire entity
* type. If an entity type is supported, there may be further decisions each
* entity type can make to evaluate if a given entity is appropriate to be
* tracked in a workspace.
*
* @param \Drupal\Core\Entity\EntityInterface $entity
* The entity we may be tracking.
*
* @return bool
* TRUE if this entity should be tracked in a workspace, FALSE otherwise.
*/
public function isEntitySupported(EntityInterface $entity): bool;
}

View File

@@ -0,0 +1,201 @@
<?php
namespace Drupal\workspaces\Entity;
use Drupal\Core\Entity\ContentEntityBase;
use Drupal\Core\Entity\EntityChangedTrait;
use Drupal\Core\Entity\EntityStorageInterface;
use Drupal\Core\Entity\EntityTypeInterface;
use Drupal\Core\Field\BaseFieldDefinition;
use Drupal\Core\StringTranslation\TranslatableMarkup;
use Drupal\user\EntityOwnerTrait;
use Drupal\workspaces\WorkspaceInterface;
/**
* The workspace entity class.
*
* @ContentEntityType(
* id = "workspace",
* label = @Translation("Workspace"),
* label_collection = @Translation("Workspaces"),
* label_singular = @Translation("workspace"),
* label_plural = @Translation("workspaces"),
* label_count = @PluralTranslation(
* singular = "@count workspace",
* plural = "@count workspaces"
* ),
* handlers = {
* "list_builder" = "\Drupal\workspaces\WorkspaceListBuilder",
* "view_builder" = "Drupal\workspaces\WorkspaceViewBuilder",
* "access" = "Drupal\workspaces\WorkspaceAccessControlHandler",
* "views_data" = "Drupal\views\EntityViewsData",
* "route_provider" = {
* "html" = "\Drupal\Core\Entity\Routing\AdminHtmlRouteProvider",
* },
* "form" = {
* "default" = "\Drupal\workspaces\Form\WorkspaceForm",
* "add" = "\Drupal\workspaces\Form\WorkspaceForm",
* "edit" = "\Drupal\workspaces\Form\WorkspaceForm",
* "delete" = "\Drupal\workspaces\Form\WorkspaceDeleteForm",
* "activate" = "\Drupal\workspaces\Form\WorkspaceActivateForm",
* },
* "workspace" = "\Drupal\workspaces\Entity\Handler\IgnoredWorkspaceHandler",
* },
* admin_permission = "administer workspaces",
* base_table = "workspace",
* revision_table = "workspace_revision",
* data_table = "workspace_field_data",
* revision_data_table = "workspace_field_revision",
* field_ui_base_route = "entity.workspace.collection",
* entity_keys = {
* "id" = "id",
* "revision" = "revision_id",
* "uuid" = "uuid",
* "label" = "label",
* "uid" = "uid",
* "owner" = "uid",
* },
* links = {
* "canonical" = "/admin/config/workflow/workspaces/manage/{workspace}",
* "add-form" = "/admin/config/workflow/workspaces/add",
* "edit-form" = "/admin/config/workflow/workspaces/manage/{workspace}/edit",
* "delete-form" = "/admin/config/workflow/workspaces/manage/{workspace}/delete",
* "activate-form" = "/admin/config/workflow/workspaces/manage/{workspace}/activate",
* "collection" = "/admin/config/workflow/workspaces",
* },
* )
*/
class Workspace extends ContentEntityBase implements WorkspaceInterface {
use EntityChangedTrait;
use EntityOwnerTrait;
/**
* {@inheritdoc}
*/
public static function baseFieldDefinitions(EntityTypeInterface $entity_type) {
$fields = parent::baseFieldDefinitions($entity_type);
$fields += static::ownerBaseFieldDefinitions($entity_type);
$fields['id'] = BaseFieldDefinition::create('string')
->setLabel(new TranslatableMarkup('Workspace ID'))
->setDescription(new TranslatableMarkup('The workspace ID.'))
->setSetting('max_length', 128)
->setRequired(TRUE)
->addConstraint('UniqueField')
->addConstraint('DeletedWorkspace')
->addPropertyConstraints('value', ['Regex' => ['pattern' => '/^[a-z0-9_]+$/']]);
$fields['label'] = BaseFieldDefinition::create('string')
->setLabel(new TranslatableMarkup('Workspace name'))
->setDescription(new TranslatableMarkup('The workspace name.'))
->setRevisionable(TRUE)
->setSetting('max_length', 128)
->setRequired(TRUE);
$fields['uid']
->setLabel(new TranslatableMarkup('Owner'))
->setDescription(new TranslatableMarkup('The workspace owner.'))
->setDisplayOptions('form', [
'type' => 'entity_reference_autocomplete',
'weight' => 5,
])
->setDisplayConfigurable('form', TRUE);
$fields['parent'] = BaseFieldDefinition::create('entity_reference')
->setLabel(new TranslatableMarkup('Parent'))
->setDescription(new TranslatableMarkup('The parent workspace.'))
->setSetting('target_type', 'workspace')
->setReadOnly(TRUE)
->setDisplayConfigurable('form', TRUE)
->setDisplayOptions('form', [
'type' => 'options_select',
'weight' => 10,
]);
$fields['changed'] = BaseFieldDefinition::create('changed')
->setLabel(new TranslatableMarkup('Changed'))
->setDescription(new TranslatableMarkup('The time that the workspace was last edited.'))
->setRevisionable(TRUE);
$fields['created'] = BaseFieldDefinition::create('created')
->setLabel(new TranslatableMarkup('Created'))
->setDescription(new TranslatableMarkup('The time that the workspace was created.'));
return $fields;
}
/**
* {@inheritdoc}
*/
public function publish() {
return \Drupal::service('workspaces.operation_factory')->getPublisher($this)->publish();
}
/**
* {@inheritdoc}
*/
public function getCreatedTime() {
return $this->get('created')->value;
}
/**
* {@inheritdoc}
*/
public function setCreatedTime($created) {
return $this->set('created', (int) $created);
}
/**
* {@inheritdoc}
*/
public function hasParent() {
return !$this->get('parent')->isEmpty();
}
/**
* {@inheritdoc}
*/
public static function preDelete(EntityStorageInterface $storage, array $entities) {
parent::preDelete($storage, $entities);
$workspace_tree = \Drupal::service('workspaces.repository')->loadTree();
// Ensure that workspaces that have descendants can not be deleted.
foreach ($entities as $entity) {
if (!empty($workspace_tree[$entity->id()]['descendants'])) {
throw new \InvalidArgumentException("The {$entity->label()} workspace can not be deleted because it has child workspaces.");
}
}
}
/**
* {@inheritdoc}
*/
public static function postDelete(EntityStorageInterface $storage, array $entities) {
parent::postDelete($storage, $entities);
/** @var \Drupal\workspaces\WorkspaceManagerInterface $workspace_manager */
$workspace_manager = \Drupal::service('workspaces.manager');
// Disable the currently active workspace if it has been deleted.
if ($workspace_manager->hasActiveWorkspace()
&& in_array($workspace_manager->getActiveWorkspace()->id(), array_keys($entities), TRUE)) {
$workspace_manager->switchToLive();
}
// Ensure that workspace batch purging does not happen inside a workspace.
$workspace_manager->executeOutsideWorkspace(function () use ($workspace_manager, $entities) {
// Add the IDs of the deleted workspaces to the list of workspaces that will
// be purged on cron.
$state = \Drupal::state();
$deleted_workspace_ids = $state->get('workspace.deleted', []);
$deleted_workspace_ids += array_combine(array_keys($entities), array_keys($entities));
$state->set('workspace.deleted', $deleted_workspace_ids);
// Trigger a batch purge to allow empty workspaces to be deleted
// immediately.
$workspace_manager->purgeDeletedWorkspacesBatch();
});
}
}

View File

@@ -0,0 +1,152 @@
<?php
namespace Drupal\workspaces;
use Drupal\Core\Access\AccessResult;
use Drupal\Core\DependencyInjection\ContainerInjectionInterface;
use Drupal\Core\Entity\EntityInterface;
use Drupal\Core\Entity\EntityTypeManagerInterface;
use Drupal\Core\Session\AccountInterface;
use Drupal\Core\StringTranslation\StringTranslationTrait;
use Symfony\Component\DependencyInjection\ContainerInterface;
/**
* Service wrapper for hooks relating to entity access control.
*
* @internal
*/
class EntityAccess implements ContainerInjectionInterface {
use StringTranslationTrait;
/**
* The entity type manager service.
*
* @var \Drupal\Core\Entity\EntityTypeManagerInterface
*/
protected $entityTypeManager;
/**
* The workspace manager service.
*
* @var \Drupal\workspaces\WorkspaceManagerInterface
*/
protected $workspaceManager;
/**
* The workspace information service.
*
* @var \Drupal\workspaces\WorkspaceInformationInterface
*/
protected $workspaceInfo;
/**
* Constructs a new EntityAccess instance.
*
* @param \Drupal\Core\Entity\EntityTypeManagerInterface $entity_type_manager
* The entity type manager service.
* @param \Drupal\workspaces\WorkspaceManagerInterface $workspace_manager
* The workspace manager service.
* @param \Drupal\workspaces\WorkspaceInformationInterface $workspace_information
* The workspace information service.
*/
public function __construct(EntityTypeManagerInterface $entity_type_manager, WorkspaceManagerInterface $workspace_manager, WorkspaceInformationInterface $workspace_information) {
$this->entityTypeManager = $entity_type_manager;
$this->workspaceManager = $workspace_manager;
$this->workspaceInfo = $workspace_information;
}
/**
* {@inheritdoc}
*/
public static function create(ContainerInterface $container) {
return new static(
$container->get('entity_type.manager'),
$container->get('workspaces.manager'),
$container->get('workspaces.information')
);
}
/**
* Implements a hook bridge for hook_entity_access().
*
* @param \Drupal\Core\Entity\EntityInterface $entity
* The entity to check access for.
* @param string $operation
* The operation being performed.
* @param \Drupal\Core\Session\AccountInterface $account
* The user account making the to check access for.
*
* @return \Drupal\Core\Access\AccessResult
* The result of the access check.
*
* @see hook_entity_access()
*/
public function entityOperationAccess(EntityInterface $entity, $operation, AccountInterface $account) {
// Workspaces themselves are handled by their own access handler and we
// should not try to do any access checks for entity types that can not
// belong to a workspace.
if (!$this->workspaceInfo->isEntitySupported($entity) || !$this->workspaceManager->hasActiveWorkspace()) {
return AccessResult::neutral();
}
// Prevent the deletion of entities with a published default revision.
if ($operation === 'delete') {
$active_workspace = $this->workspaceManager->getActiveWorkspace();
$is_deletable = $this->workspaceInfo->isEntityDeletable($entity, $active_workspace);
return AccessResult::forbiddenIf(!$is_deletable)
->addCacheableDependency($entity)
->addCacheableDependency($active_workspace);
}
return $this->bypassAccessResult($account);
}
/**
* Implements a hook bridge for hook_entity_create_access().
*
* @param \Drupal\Core\Session\AccountInterface $account
* The user account making the to check access for.
* @param array $context
* The context of the access check.
* @param string $entity_bundle
* The bundle of the entity.
*
* @return \Drupal\Core\Access\AccessResult
* The result of the access check.
*
* @see hook_entity_create_access()
*/
public function entityCreateAccess(AccountInterface $account, array $context, $entity_bundle) {
// Workspaces themselves are handled by their own access handler and we
// should not try to do any access checks for entity types that can not
// belong to a workspace.
$entity_type = $this->entityTypeManager->getDefinition($context['entity_type_id']);
if (!$this->workspaceInfo->isEntityTypeSupported($entity_type) || !$this->workspaceManager->hasActiveWorkspace()) {
return AccessResult::neutral();
}
return $this->bypassAccessResult($account);
}
/**
* Checks the 'bypass' permissions.
*
* @param \Drupal\Core\Session\AccountInterface $account
* The user account making the to check access for.
*
* @return \Drupal\Core\Access\AccessResult
* The result of the access check.
*/
protected function bypassAccessResult(AccountInterface $account) {
// This approach assumes that the current "global" active workspace is
// correct, i.e. if you're "in" a given workspace then you get ALL THE PERMS
// to ALL THE THINGS! That's why this is a dangerous permission.
$active_workspace = $this->workspaceManager->getActiveWorkspace();
return AccessResult::allowedIf($active_workspace->getOwnerId() == $account->id())->cachePerUser()->addCacheableDependency($active_workspace)
->andIf(AccessResult::allowedIfHasPermission($account, 'bypass entity access own workspace'));
}
}

View File

@@ -0,0 +1,351 @@
<?php
namespace Drupal\workspaces;
use Drupal\Core\DependencyInjection\ContainerInjectionInterface;
use Drupal\Core\Entity\EntityInterface;
use Drupal\Core\Entity\EntityTypeManagerInterface;
use Drupal\Core\Entity\RevisionableInterface;
use Drupal\Core\Form\FormStateInterface;
use Drupal\Core\StringTranslation\StringTranslationTrait;
use Symfony\Component\DependencyInjection\ContainerInterface;
/**
* Defines a class for reacting to entity events.
*
* @internal
*/
class EntityOperations implements ContainerInjectionInterface {
use StringTranslationTrait;
/**
* The entity type manager service.
*
* @var \Drupal\Core\Entity\EntityTypeManagerInterface
*/
protected $entityTypeManager;
/**
* The workspace manager service.
*
* @var \Drupal\workspaces\WorkspaceManagerInterface
*/
protected $workspaceManager;
/**
* The workspace association service.
*
* @var \Drupal\workspaces\WorkspaceAssociationInterface
*/
protected $workspaceAssociation;
/**
* The workspace information service.
*
* @var \Drupal\workspaces\WorkspaceInformationInterface
*/
protected $workspaceInfo;
/**
* Constructs a new EntityOperations instance.
*
* @param \Drupal\Core\Entity\EntityTypeManagerInterface $entity_type_manager
* The entity type manager service.
* @param \Drupal\workspaces\WorkspaceManagerInterface $workspace_manager
* The workspace manager service.
* @param \Drupal\workspaces\WorkspaceAssociationInterface $workspace_association
* The workspace association service.
* @param \Drupal\workspaces\WorkspaceInformationInterface $workspace_information
* The workspace information service.
*/
public function __construct(EntityTypeManagerInterface $entity_type_manager, WorkspaceManagerInterface $workspace_manager, WorkspaceAssociationInterface $workspace_association, WorkspaceInformationInterface $workspace_information) {
$this->entityTypeManager = $entity_type_manager;
$this->workspaceManager = $workspace_manager;
$this->workspaceAssociation = $workspace_association;
$this->workspaceInfo = $workspace_information;
}
/**
* {@inheritdoc}
*/
public static function create(ContainerInterface $container) {
return new static(
$container->get('entity_type.manager'),
$container->get('workspaces.manager'),
$container->get('workspaces.association'),
$container->get('workspaces.information')
);
}
/**
* Acts on entity IDs before they are loaded.
*
* @see hook_entity_preload()
*/
public function entityPreload(array $ids, $entity_type_id) {
$entities = [];
$entity_type = $this->entityTypeManager->getDefinition($entity_type_id);
if (!$this->workspaceInfo->isEntityTypeSupported($entity_type) || !$this->workspaceManager->hasActiveWorkspace()) {
return $entities;
}
// Get a list of revision IDs for entities that have a revision set for the
// current active workspace. If an entity has multiple revisions set for a
// workspace, only the one with the highest ID is returned.
if ($tracked_entities = $this->workspaceAssociation->getTrackedEntities($this->workspaceManager->getActiveWorkspace()->id(), $entity_type_id, $ids)) {
// Bail out early if there are no tracked entities of this type.
if (!isset($tracked_entities[$entity_type_id])) {
return $entities;
}
/** @var \Drupal\Core\Entity\RevisionableStorageInterface $storage */
$storage = $this->entityTypeManager->getStorage($entity_type_id);
// Swap out every entity which has a revision set for the current active
// workspace.
foreach ($storage->loadMultipleRevisions(array_keys($tracked_entities[$entity_type_id])) as $revision) {
$entities[$revision->id()] = $revision;
}
}
return $entities;
}
/**
* Acts on an entity before it is created or updated.
*
* @param \Drupal\Core\Entity\EntityInterface $entity
* The entity being saved.
*
* @see hook_entity_presave()
*/
public function entityPresave(EntityInterface $entity) {
if ($this->shouldSkipOperations($entity)) {
return;
}
// Disallow any change to an unsupported entity when we are not in the
// default workspace.
if (!$this->workspaceInfo->isEntitySupported($entity)) {
throw new \RuntimeException('This entity can only be saved in the default workspace.');
}
/** @var \Drupal\Core\Entity\ContentEntityInterface|\Drupal\Core\Entity\EntityPublishedInterface $entity */
if (!$entity->isNew() && !$entity->isSyncing()) {
// Force a new revision if the entity is not replicating.
$entity->setNewRevision(TRUE);
// All entities in the non-default workspace are pending revisions,
// regardless of their publishing status. This means that when creating
// a published pending revision in a non-default workspace it will also be
// a published pending revision in the default workspace, however, it will
// become the default revision only when it is replicated to the default
// workspace.
$entity->isDefaultRevision(FALSE);
}
// In ::entityFormEntityBuild() we mark the entity as a non-default revision
// so that validation constraints can rely on $entity->isDefaultRevision()
// always returning FALSE when an entity form is submitted in a workspace.
// However, after validation has run, we need to revert that flag so the
// first revision of a new entity is correctly seen by the system as the
// default revision.
if ($entity->isNew()) {
$entity->isDefaultRevision(TRUE);
}
// Track the workspaces in which the new revision was saved.
if (!$entity->isSyncing()) {
$field_name = $entity->getEntityType()->getRevisionMetadataKey('workspace');
$entity->{$field_name}->target_id = $this->workspaceManager->getActiveWorkspace()->id();
}
// When a new published entity is inserted in a non-default workspace, we
// actually want two revisions to be saved:
// - An unpublished default revision in the default ('live') workspace.
// - A published pending revision in the current workspace.
if ($entity->isNew() && $entity->isPublished()) {
// Keep track of the publishing status in a dynamic property for
// ::entityInsert(), then unpublish the default revision.
// @todo Remove this dynamic property once we have an API for associating
// temporary data with an entity: https://www.drupal.org/node/2896474.
$entity->_initialPublished = TRUE;
$entity->setUnpublished();
}
}
/**
* Responds to the creation of a new entity.
*
* @param \Drupal\Core\Entity\EntityInterface $entity
* The entity that was just saved.
*
* @see hook_entity_insert()
*/
public function entityInsert(EntityInterface $entity) {
if ($this->shouldSkipOperations($entity) || !$this->workspaceInfo->isEntitySupported($entity)) {
return;
}
$this->workspaceAssociation->trackEntity($entity, $this->workspaceManager->getActiveWorkspace());
// When a published entity is created in a workspace, it should remain
// published only in that workspace, and unpublished in the live workspace.
// It is first saved as unpublished for the default revision, then
// immediately a second revision is created which is published and attached
// to the workspace. This ensures that the initial version of the entity
// does not 'leak' into the live site. This differs from edits to existing
// entities where there is already a valid default revision for the live
// workspace.
if (isset($entity->_initialPublished)) {
$entity->setPublished();
$entity->isDefaultRevision(FALSE);
$entity->save();
}
}
/**
* Responds to updates to an entity.
*
* @param \Drupal\Core\Entity\EntityInterface $entity
* The entity that was just saved.
*
* @see hook_entity_update()
*/
public function entityUpdate(EntityInterface $entity) {
if ($this->shouldSkipOperations($entity) || !$this->workspaceInfo->isEntitySupported($entity)) {
return;
}
// Only track new revisions.
/** @var \Drupal\Core\Entity\RevisionableInterface $entity */
if ($entity->getLoadedRevisionId() != $entity->getRevisionId()) {
$this->workspaceAssociation->trackEntity($entity, $this->workspaceManager->getActiveWorkspace());
}
}
/**
* Acts after an entity translation has been added.
*
* @param \Drupal\Core\Entity\EntityInterface $translation
* The translation that was added.
*
* @see hook_entity_translation_insert()
*/
public function entityTranslationInsert(EntityInterface $translation): void {
if ($this->shouldSkipOperations($translation)
|| !$this->workspaceInfo->isEntitySupported($translation)
|| $translation->isSyncing()
) {
return;
}
// When a new translation is added to an existing entity, we need to add
// that translation to the default revision as well, otherwise the new
// translation wouldn't show up in entity queries or views which use the
// field data table as the base table.
$this->workspaceManager->executeOutsideWorkspace(function () use ($translation) {
$storage = $this->entityTypeManager->getStorage($translation->getEntityTypeId());
$default_revision = $storage->load($translation->id());
$langcode = $translation->language()->getId();
if (!$default_revision->hasTranslation($langcode)) {
$default_revision_translation = $default_revision->addTranslation($langcode, $translation->toArray());
$default_revision_translation->setUnpublished();
$default_revision_translation->setSyncing(TRUE);
$default_revision_translation->save();
}
});
}
/**
* Acts on an entity before it is deleted.
*
* @param \Drupal\Core\Entity\EntityInterface $entity
* The entity being deleted.
*
* @see hook_entity_predelete()
*/
public function entityPredelete(EntityInterface $entity) {
if ($this->shouldSkipOperations($entity)) {
return;
}
// Prevent the entity from being deleted if the entity type does not have
// support for workspaces, or if the entity has a published default
// revision.
$active_workspace = $this->workspaceManager->getActiveWorkspace();
if (!$this->workspaceInfo->isEntitySupported($entity) || !$this->workspaceInfo->isEntityDeletable($entity, $active_workspace)) {
throw new \RuntimeException("This {$entity->getEntityType()->getSingularLabel()} can only be deleted in the Live workspace.");
}
}
/**
* Alters entity forms to disallow concurrent editing in multiple workspaces.
*
* @param array $form
* An associative array containing the structure of the form.
* @param \Drupal\Core\Form\FormStateInterface $form_state
* The current state of the form.
* @param string $form_id
* The form ID.
*
* @see hook_form_alter()
*/
public function entityFormAlter(array &$form, FormStateInterface $form_state, $form_id) {
$entity = $form_state->getFormObject()->getEntity();
if (!$this->workspaceInfo->isEntitySupported($entity) && !$this->workspaceInfo->isEntityIgnored($entity)) {
return;
}
// For supported and ignored entity types, signal the fact that this form is
// safe to use in a workspace.
// @see \Drupal\workspaces\FormOperations::validateForm()
$form_state->set('workspace_safe', TRUE);
// There is nothing more to do for ignored entity types.
if ($this->workspaceInfo->isEntityIgnored($entity)) {
return;
}
// Add an entity builder to the form which marks the edited entity object as
// a pending revision. This is needed so validation constraints like
// \Drupal\path\Plugin\Validation\Constraint\PathAliasConstraintValidator
// know in advance (before hook_entity_presave()) that the new revision will
// be a pending one.
if ($this->workspaceManager->hasActiveWorkspace()) {
$form['#entity_builders'][] = [static::class, 'entityFormEntityBuild'];
}
}
/**
* Entity builder that marks all supported entities as pending revisions.
*/
public static function entityFormEntityBuild($entity_type_id, RevisionableInterface $entity, &$form, FormStateInterface &$form_state) {
// Ensure that all entity forms are signaling that a new revision will be
// created.
$entity->setNewRevision(TRUE);
// Set the non-default revision flag so that validation constraints are also
// aware that a pending revision is about to be created.
$entity->isDefaultRevision(FALSE);
}
/**
* Determines whether we need to react on entity operations.
*
* @param \Drupal\Core\Entity\EntityInterface $entity
* The entity to check.
*
* @return bool
* Returns TRUE if entity operations should not be altered, FALSE otherwise.
*/
protected function shouldSkipOperations(EntityInterface $entity) {
// We should not react on entity operations when the entity is ignored or
// when we're not in a workspace context.
return $this->workspaceInfo->isEntityIgnored($entity) || !$this->workspaceManager->hasActiveWorkspace();
}
}

View File

@@ -0,0 +1,12 @@
<?php
namespace Drupal\workspaces\EntityQuery;
/**
* Workspaces PostgreSQL-specific entity query implementation.
*
* @internal
*/
class PgsqlQueryFactory extends QueryFactory {
}

View File

@@ -0,0 +1,45 @@
<?php
namespace Drupal\workspaces\EntityQuery;
use Drupal\Core\Entity\Query\Sql\Query as BaseQuery;
/**
* Alters entity queries to use a workspace revision instead of the default one.
*/
class Query extends BaseQuery {
use QueryTrait {
prepare as traitPrepare;
}
/**
* {@inheritdoc}
*/
public function prepare() {
$this->traitPrepare();
// If the prepare() method from the trait decided that we need to alter this
// query, we need to re-define the key fields for fetchAllKeyed() as SQL
// expressions.
if ($this->sqlQuery->getMetaData('active_workspace_id')) {
$id_field = $this->entityType->getKey('id');
$revision_field = $this->entityType->getKey('revision');
// Since the query is against the base table, we have to take into account
// that the revision ID might come from the workspace_association
// relationship, and, as a consequence, the revision ID field is no longer
// a simple SQL field but an expression.
$this->sqlFields = [];
$this->sqlQuery->addExpression("COALESCE([workspace_association].[target_entity_revision_id], [base_table].[$revision_field])", $revision_field);
$this->sqlQuery->addExpression("[base_table].[$id_field]", $id_field);
$this->sqlGroupBy['workspace_association.target_entity_revision_id'] = 'workspace_association.target_entity_revision_id';
$this->sqlGroupBy["base_table.$id_field"] = "base_table.$id_field";
$this->sqlGroupBy["base_table.$revision_field"] = "base_table.$revision_field";
}
return $this;
}
}

View File

@@ -0,0 +1,31 @@
<?php
namespace Drupal\workspaces\EntityQuery;
use Drupal\Core\Entity\Query\Sql\QueryAggregate as BaseQueryAggregate;
/**
* Alters aggregate entity queries to use a workspace revision if possible.
*/
class QueryAggregate extends BaseQueryAggregate {
use QueryTrait {
prepare as traitPrepare;
}
/**
* {@inheritdoc}
*/
public function prepare() {
// Aggregate entity queries do not return an array of entity IDs keyed by
// revision IDs, they only return the values of the aggregated fields, so we
// don't need to add any expressions like we do in
// \Drupal\workspaces\EntityQuery\Query::prepare().
$this->traitPrepare();
// Throw away the ID fields.
$this->sqlFields = [];
return $this;
}
}

View File

@@ -0,0 +1,44 @@
<?php
namespace Drupal\workspaces\EntityQuery;
use Drupal\Core\Database\Connection;
use Drupal\Core\Entity\EntityTypeInterface;
use Drupal\Core\Entity\Query\QueryBase;
use Drupal\Core\Entity\Query\Sql\QueryFactory as BaseQueryFactory;
use Drupal\workspaces\WorkspaceInformationInterface;
use Drupal\workspaces\WorkspaceManagerInterface;
/**
* Workspaces-specific entity query implementation.
*
* @internal
*/
class QueryFactory extends BaseQueryFactory {
public function __construct(
Connection $connection,
protected readonly WorkspaceManagerInterface $workspaceManager,
protected readonly WorkspaceInformationInterface $workspaceInfo,
) {
$this->connection = $connection;
$this->namespaces = QueryBase::getNamespaces($this);
}
/**
* {@inheritdoc}
*/
public function get(EntityTypeInterface $entity_type, $conjunction) {
$class = QueryBase::getClass($this->namespaces, 'Query');
return new $class($entity_type, $conjunction, $this->connection, $this->namespaces, $this->workspaceManager, $this->workspaceInfo);
}
/**
* {@inheritdoc}
*/
public function getAggregate(EntityTypeInterface $entity_type, $conjunction) {
$class = QueryBase::getClass($this->namespaces, 'QueryAggregate');
return new $class($entity_type, $conjunction, $this->connection, $this->namespaces, $this->workspaceManager, $this->workspaceInfo);
}
}

View File

@@ -0,0 +1,99 @@
<?php
namespace Drupal\workspaces\EntityQuery;
use Drupal\Core\Database\Connection;
use Drupal\Core\Entity\EntityTypeInterface;
use Drupal\workspaces\WorkspaceInformationInterface;
use Drupal\workspaces\WorkspaceManagerInterface;
/**
* Provides workspaces-specific helpers for altering entity queries.
*
* @internal
*/
trait QueryTrait {
/**
* The workspace manager.
*
* @var \Drupal\workspaces\WorkspaceManagerInterface
*/
protected $workspaceManager;
/**
* The workspace information service.
*
* @var \Drupal\workspaces\WorkspaceInformationInterface
*/
protected $workspaceInfo;
/**
* Constructs a Query object.
*
* @param \Drupal\Core\Entity\EntityTypeInterface $entity_type
* The entity type definition.
* @param string $conjunction
* - AND: all of the conditions on the query need to match.
* - OR: at least one of the conditions on the query need to match.
* @param \Drupal\Core\Database\Connection $connection
* The database connection to run the query against.
* @param array $namespaces
* List of potential namespaces of the classes belonging to this query.
* @param \Drupal\workspaces\WorkspaceManagerInterface $workspace_manager
* The workspace manager.
* @param \Drupal\workspaces\WorkspaceInformationInterface $workspace_information
* The workspace information service.
*/
public function __construct(EntityTypeInterface $entity_type, $conjunction, Connection $connection, array $namespaces, WorkspaceManagerInterface $workspace_manager, WorkspaceInformationInterface $workspace_information) {
parent::__construct($entity_type, $conjunction, $connection, $namespaces);
$this->workspaceManager = $workspace_manager;
$this->workspaceInfo = $workspace_information;
}
/**
* {@inheritdoc}
*/
public function prepare() {
parent::prepare();
// Do not alter entity revision queries.
// @todo How about queries for the latest revision? Should we alter them to
// look for the latest workspace-specific revision?
if ($this->allRevisions) {
return $this;
}
// Only alter the query if the active workspace is not the default one and
// the entity type is supported.
if ($this->workspaceInfo->isEntityTypeSupported($this->entityType) && $this->workspaceManager->hasActiveWorkspace()) {
$active_workspace = $this->workspaceManager->getActiveWorkspace();
$this->sqlQuery->addMetaData('active_workspace_id', $active_workspace->id());
$this->sqlQuery->addMetaData('simple_query', FALSE);
// LEFT JOIN 'workspace_association' to the base table of the query so we
// can properly include live content along with a possible workspace
// revision.
$id_field = $this->entityType->getKey('id');
$this->sqlQuery->leftJoin('workspace_association', 'workspace_association', "[%alias].[target_entity_type_id] = '{$this->entityTypeId}' AND [%alias].[target_entity_id] = [base_table].[$id_field] AND [%alias].[workspace] = '{$active_workspace->id()}'");
}
return $this;
}
/**
* {@inheritdoc}
*/
public function isSimpleQuery() {
// We declare that this is not a simple query in
// \Drupal\workspaces\EntityQuery\QueryTrait::prepare(), but that's not
// enough because the parent method can return TRUE in some circumstances.
if ($this->sqlQuery->getMetaData('active_workspace_id')) {
return FALSE;
}
return parent::isSimpleQuery();
}
}

View File

@@ -0,0 +1,157 @@
<?php
namespace Drupal\workspaces\EntityQuery;
use Drupal\Core\Database\Query\SelectInterface;
use Drupal\Core\Entity\EntityType;
use Drupal\Core\Entity\Query\Sql\Tables as BaseTables;
use Drupal\Core\Field\FieldStorageDefinitionInterface;
/**
* Alters entity queries to use a workspace revision instead of the default one.
*/
class Tables extends BaseTables {
/**
* The workspace information service.
*
* @var \Drupal\workspaces\WorkspaceInformationInterface
*/
protected $workspaceInfo;
/**
* Workspace association table array, key is base table name, value is alias.
*
* @var array
*/
protected $contentWorkspaceTables = [];
/**
* Keeps track of the entity type IDs for each base table of the query.
*
* The array is keyed by the base table alias and the values are entity type
* IDs.
*
* @var array
*/
protected $baseTablesEntityType = [];
/**
* {@inheritdoc}
*/
public function __construct(SelectInterface $sql_query) {
parent::__construct($sql_query);
$this->workspaceInfo = \Drupal::service('workspaces.information');
// The join between the first 'workspace_association' table and base table
// of the query is done in
// \Drupal\workspaces\EntityQuery\QueryTrait::prepare(), so we need to
// initialize its entry manually.
if ($this->sqlQuery->getMetaData('active_workspace_id')) {
$this->contentWorkspaceTables['base_table'] = 'workspace_association';
$this->baseTablesEntityType['base_table'] = $this->sqlQuery->getMetaData('entity_type');
}
}
/**
* {@inheritdoc}
*/
public function addField($field, $type, $langcode) {
// The parent method uses shared and dedicated revision tables only when the
// entity query is instructed to query all revisions. However, if we are
// looking for workspace-specific revisions, we have to force the parent
// method to always pick the revision tables if the field being queried is
// revisionable.
if ($this->sqlQuery->getMetaData('active_workspace_id')) {
$previous_all_revisions = $this->sqlQuery->getMetaData('all_revisions');
$this->sqlQuery->addMetaData('all_revisions', TRUE);
}
$alias = parent::addField($field, $type, $langcode);
// Restore the 'all_revisions' metadata because we don't want to interfere
// with the rest of the query.
if (isset($previous_all_revisions)) {
$this->sqlQuery->addMetaData('all_revisions', $previous_all_revisions);
}
return $alias;
}
/**
* {@inheritdoc}
*/
protected function addJoin($type, $table, $join_condition, $langcode, $delta = NULL) {
if ($this->sqlQuery->getMetaData('active_workspace_id')) {
// The join condition for a shared or dedicated field table is in the form
// of "%alias.$id_field = $base_table.$id_field". Whenever we join a field
// table we have to check:
// 1) if $base_table is of an entity type that can belong to a workspace;
// 2) if $id_field is the revision key of that entity type or the special
// 'revision_id' string used when joining dedicated field tables.
// If those two conditions are met, we have to update the join condition
// to also look for a possible workspace-specific revision using COALESCE.
$condition_parts = explode(' = ', $join_condition);
$condition_parts_1 = str_replace(['[', ']'], '', $condition_parts[1]);
[$base_table, $id_field] = explode('.', $condition_parts_1);
if (isset($this->baseTablesEntityType[$base_table])) {
$entity_type_id = $this->baseTablesEntityType[$base_table];
$revision_key = $this->entityTypeManager->getActiveDefinition($entity_type_id)->getKey('revision');
if ($id_field === $revision_key || $id_field === 'revision_id') {
$workspace_association_table = $this->contentWorkspaceTables[$base_table];
$join_condition = "{$condition_parts[0]} = COALESCE($workspace_association_table.target_entity_revision_id, {$condition_parts[1]})";
}
}
}
return parent::addJoin($type, $table, $join_condition, $langcode, $delta);
}
/**
* {@inheritdoc}
*/
protected function addNextBaseTable(EntityType $entity_type, $table, $sql_column, FieldStorageDefinitionInterface $field_storage) {
$next_base_table_alias = parent::addNextBaseTable($entity_type, $table, $sql_column, $field_storage);
$active_workspace_id = $this->sqlQuery->getMetaData('active_workspace_id');
if ($active_workspace_id && $this->workspaceInfo->isEntityTypeSupported($entity_type)) {
$this->addWorkspaceAssociationJoin($entity_type->id(), $next_base_table_alias, $active_workspace_id);
}
return $next_base_table_alias;
}
/**
* Adds a new join to the 'workspace_association' table for an entity base table.
*
* This method assumes that the active workspace has already been determined
* to be a non-default workspace.
*
* @param string $entity_type_id
* The ID of the entity type whose base table we are joining.
* @param string $base_table_alias
* The alias of the entity type's base table.
* @param string $active_workspace_id
* The ID of the active workspace.
*
* @return string
* The alias of the joined table.
*/
public function addWorkspaceAssociationJoin($entity_type_id, $base_table_alias, $active_workspace_id) {
if (!isset($this->contentWorkspaceTables[$base_table_alias])) {
$entity_type = $this->entityTypeManager->getActiveDefinition($entity_type_id);
$id_field = $entity_type->getKey('id');
// LEFT join the Workspace association entity's table so we can properly
// include live content along with a possible workspace-specific revision.
$this->contentWorkspaceTables[$base_table_alias] = $this->sqlQuery->leftJoin('workspace_association', NULL, "[%alias].[target_entity_type_id] = '$entity_type_id' AND [%alias].[target_entity_id] = [$base_table_alias].[$id_field] AND [%alias].[workspace] = '$active_workspace_id'");
$this->baseTablesEntityType[$base_table_alias] = $entity_type->id();
}
return $this->contentWorkspaceTables[$base_table_alias];
}
}

View File

@@ -0,0 +1,152 @@
<?php
namespace Drupal\workspaces;
use Drupal\Core\DependencyInjection\ContainerInjectionInterface;
use Drupal\Core\Entity\EntityPublishedInterface;
use Drupal\Core\Entity\EntityTypeInterface;
use Drupal\Core\Field\BaseFieldDefinition;
use Drupal\Core\StringTranslation\TranslatableMarkup;
use Drupal\workspaces\Entity\Handler\BlockContentWorkspaceHandler;
use Drupal\workspaces\Entity\Handler\DefaultWorkspaceHandler;
use Drupal\workspaces\Entity\Handler\IgnoredWorkspaceHandler;
use Symfony\Component\DependencyInjection\ContainerInterface;
/**
* Manipulates entity type information.
*
* This class contains primarily bridged hooks for compile-time or
* cache-clear-time hooks. Runtime hooks should be placed in EntityOperations.
*
* @internal
*/
class EntityTypeInfo implements ContainerInjectionInterface {
public function __construct(
protected readonly WorkspaceInformationInterface $workspaceInfo,
) {
}
/**
* {@inheritdoc}
*/
public static function create(ContainerInterface $container) {
return new static(
$container->get('workspaces.information')
);
}
/**
* Adds workspace support info to eligible entity types.
*
* @param \Drupal\Core\Entity\EntityTypeInterface[] $entity_types
* An associative array of all entity type definitions, keyed by the entity
* type name. Passed by reference.
*
* @see hook_entity_type_build()
*/
public function entityTypeBuild(array &$entity_types) {
foreach ($entity_types as $entity_type) {
if ($entity_type->hasHandlerClass('workspace')) {
continue;
}
// Revisionable and publishable entity types are always supported.
if ($entity_type->entityClassImplements(EntityPublishedInterface::class) && $entity_type->isRevisionable()) {
$entity_type->setHandlerClass('workspace', DefaultWorkspaceHandler::class);
// Support for custom blocks has to be determined on a per-entity
// basis.
if ($entity_type->id() === 'block_content') {
$entity_type->setHandlerClass('workspace', BlockContentWorkspaceHandler::class);
}
}
// The 'file' entity type is allowed to perform CRUD operations inside a
// workspace without being tracked.
if ($entity_type->id() === 'file') {
$entity_type->setHandlerClass('workspace', IgnoredWorkspaceHandler::class);
}
// Internal entity types are allowed to perform CRUD operations inside a
// workspace.
if ($entity_type->isInternal()) {
$entity_type->setHandlerClass('workspace', IgnoredWorkspaceHandler::class);
}
}
}
/**
* Adds Workspace configuration to appropriate entity types.
*
* @param \Drupal\Core\Entity\EntityTypeInterface[] $entity_types
* An array of entity types.
*
* @see hook_entity_type_alter()
*/
public function entityTypeAlter(array &$entity_types) {
foreach ($entity_types as $entity_type) {
if (!$this->workspaceInfo->isEntityTypeSupported($entity_type)) {
continue;
}
// Workspace-support status has been declared in the "build" phase, now we
// can use that information and add additional configuration in the
// "alter" phase.
$entity_type->addConstraint('EntityWorkspaceConflict');
$entity_type->setRevisionMetadataKey('workspace', 'workspace');
// Non-default workspaces display the active revision on the canonical
// route of an entity, so the latest version route is no longer needed.
$link_templates = $entity_type->get('links');
unset($link_templates['latest-version']);
$entity_type->set('links', $link_templates);
}
}
/**
* Alters field plugin definitions.
*
* @param array[] $definitions
* An array of field plugin definitions.
*
* @see hook_field_info_alter()
*/
public function fieldInfoAlter(&$definitions) {
if (isset($definitions['entity_reference'])) {
$definitions['entity_reference']['constraints']['EntityReferenceSupportedNewEntities'] = [];
}
// Allow path aliases to be changed in workspace-specific pending revisions.
if (isset($definitions['path'])) {
unset($definitions['path']['constraints']['PathAlias']);
}
}
/**
* Provides custom base field definitions for a content entity type.
*
* @param \Drupal\Core\Entity\EntityTypeInterface $entity_type
* The entity type definition.
*
* @return \Drupal\Core\Field\FieldDefinitionInterface[]
* An array of field definitions, keyed by field name.
*
* @see hook_entity_base_field_info()
*/
public function entityBaseFieldInfo(EntityTypeInterface $entity_type) {
if ($this->workspaceInfo->isEntityTypeSupported($entity_type)) {
$field_name = $entity_type->getRevisionMetadataKey('workspace');
$fields[$field_name] = BaseFieldDefinition::create('entity_reference')
->setLabel(new TranslatableMarkup('Workspace'))
->setDescription(new TranslatableMarkup('Indicates the workspace that this revision belongs to.'))
->setSetting('target_type', 'workspace')
->setInternal(TRUE)
->setTranslatable(FALSE)
->setRevisionable(TRUE);
return $fields;
}
}
}

View File

@@ -0,0 +1,9 @@
<?php
namespace Drupal\workspaces\Event;
/**
* Defines the post-publish event class.
*/
class WorkspacePostPublishEvent extends WorkspacePublishEvent {
}

View File

@@ -0,0 +1,9 @@
<?php
namespace Drupal\workspaces\Event;
/**
* Defines the pre-publish event class.
*/
class WorkspacePrePublishEvent extends WorkspacePublishEvent {
}

View File

@@ -0,0 +1,112 @@
<?php
namespace Drupal\workspaces\Event;
use Drupal\Core\StringTranslation\TranslatableMarkup;
use Drupal\workspaces\WorkspaceInterface;
use Drupal\Component\EventDispatcher\Event;
/**
* Defines the workspace publish event.
*/
abstract class WorkspacePublishEvent extends Event {
/**
* The IDs of the entities that are being published.
*/
protected readonly array $publishedRevisionIds;
/**
* Whether an event subscriber requested the publishing to be stopped.
*/
protected bool $publishingStopped = FALSE;
/**
* The reason why publishing stopped. For use in messages.
*/
protected string $publishingStoppedReason = '';
/**
* Constructs a new WorkspacePublishEvent.
*
* @param \Drupal\workspaces\WorkspaceInterface $workspace
* The workspace.
* @param array $published_revision_ids
* The IDs of the entities that are being published.
*/
public function __construct(
protected readonly WorkspaceInterface $workspace,
array $published_revision_ids,
) {
$this->publishedRevisionIds = $published_revision_ids;
}
/**
* Gets the workspace.
*
* @return \Drupal\workspaces\WorkspaceInterface
* The workspace.
*/
public function getWorkspace(): WorkspaceInterface {
return $this->workspace;
}
/**
* Gets the entity IDs that are being published as part of the workspace.
*
* @return array
* Returns a multidimensional array where the first level keys are entity
* type IDs and the values are an array of entity IDs keyed by revision IDs.
*/
public function getPublishedRevisionIds(): array {
return $this->publishedRevisionIds;
}
/**
* Determines whether a subscriber requested the publishing to be stopped.
*
* @return bool
* TRUE if the publishing of the workspace should be stopped, FALSE
* otherwise.
*/
public function isPublishingStopped(): bool {
return $this->publishingStopped;
}
/**
* Signals that the workspace publishing should be aborted.
*
* @return $this
*/
public function stopPublishing(): static {
$this->publishingStopped = TRUE;
return $this;
}
/**
* Gets the reason for stopping the workspace publication.
*
* @return string|\Drupal\Core\StringTranslation\TranslatableMarkup
* The reason for stopping the workspace publication or an empty string if
* no reason is provided.
*/
public function getPublishingStoppedReason(): string|TranslatableMarkup {
return $this->publishingStoppedReason;
}
/**
* Sets the reason for stopping the workspace publication.
*
* @param string|\Stringable $reason
* The reason for stopping the workspace publication.
*
* @return $this
*/
public function setPublishingStoppedReason(string|\Stringable $reason): static {
$this->publishingStoppedReason = $reason;
return $this;
}
}

View File

@@ -0,0 +1,161 @@
<?php
namespace Drupal\workspaces\EventSubscriber;
use Drupal\Core\Entity\EntityDefinitionUpdateManagerInterface;
use Drupal\Core\Entity\EntityLastInstalledSchemaRepositoryInterface;
use Drupal\Core\Entity\EntityTypeEventSubscriberTrait;
use Drupal\Core\Entity\EntityTypeInterface;
use Drupal\Core\Entity\EntityTypeListenerInterface;
use Drupal\Core\Field\BaseFieldDefinition;
use Drupal\Core\StringTranslation\StringTranslationTrait;
use Drupal\workspaces\WorkspaceInformationInterface;
use Symfony\Component\EventDispatcher\EventSubscriberInterface;
/**
* Defines a class for listening to entity schema changes.
*/
class EntitySchemaSubscriber implements EntityTypeListenerInterface, EventSubscriberInterface {
use EntityTypeEventSubscriberTrait;
use StringTranslationTrait;
/**
* The definition update manager.
*
* @var \Drupal\Core\Entity\EntityDefinitionUpdateManagerInterface
*/
protected $entityDefinitionUpdateManager;
/**
* The last installed schema definitions.
*
* @var \Drupal\Core\Entity\EntityLastInstalledSchemaRepositoryInterface
*/
protected $entityLastInstalledSchemaRepository;
/**
* The workspace information service.
*
* @var \Drupal\workspaces\WorkspaceInformationInterface
*/
protected $workspaceInfo;
/**
* Constructs a new EntitySchemaSubscriber.
*
* @param \Drupal\Core\Entity\EntityDefinitionUpdateManagerInterface $entityDefinitionUpdateManager
* Definition update manager.
* @param \Drupal\Core\Entity\EntityLastInstalledSchemaRepositoryInterface $entityLastInstalledSchemaRepository
* Last definitions.
* @param \Drupal\workspaces\WorkspaceInformationInterface $workspace_information
* The workspace information service.
*/
public function __construct(EntityDefinitionUpdateManagerInterface $entityDefinitionUpdateManager, EntityLastInstalledSchemaRepositoryInterface $entityLastInstalledSchemaRepository, WorkspaceInformationInterface $workspace_information) {
$this->entityDefinitionUpdateManager = $entityDefinitionUpdateManager;
$this->entityLastInstalledSchemaRepository = $entityLastInstalledSchemaRepository;
$this->workspaceInfo = $workspace_information;
}
/**
* {@inheritdoc}
*/
public static function getSubscribedEvents(): array {
return static::getEntityTypeEvents();
}
/**
* {@inheritdoc}
*/
public function onEntityTypeCreate(EntityTypeInterface $entity_type) {
// If the entity type is supported by Workspaces, add the revision metadata
// field.
if ($this->workspaceInfo->isEntityTypeSupported($entity_type)) {
$this->addRevisionMetadataField($entity_type);
}
}
/**
* {@inheritdoc}
*/
public function onFieldableEntityTypeCreate(EntityTypeInterface $entity_type, array $field_storage_definitions) {
$this->onEntityTypeCreate($entity_type);
}
/**
* {@inheritdoc}
*/
public function onEntityTypeUpdate(EntityTypeInterface $entity_type, EntityTypeInterface $original) {
// If the entity type is now supported by Workspaces, add the revision
// metadata field.
if ($this->workspaceInfo->isEntityTypeSupported($entity_type) && !$this->workspaceInfo->isEntityTypeSupported($original)) {
$this->addRevisionMetadataField($entity_type);
}
// If the entity type is no longer supported by Workspaces, remove the
// revision metadata field.
if ($this->workspaceInfo->isEntityTypeSupported($original) && !$this->workspaceInfo->isEntityTypeSupported($entity_type)) {
$revision_metadata_keys = $original->get('revision_metadata_keys');
$field_storage_definition = $this->entityLastInstalledSchemaRepository->getLastInstalledFieldStorageDefinitions($entity_type->id())[$revision_metadata_keys['workspace']];
$this->entityDefinitionUpdateManager->uninstallFieldStorageDefinition($field_storage_definition);
// We are only removing a revision metadata key so we don't need to go
// through the entity update process.
$entity_type->setRevisionMetadataKey('workspace', NULL);
$this->entityLastInstalledSchemaRepository->setLastInstalledDefinition($entity_type);
}
}
/**
* {@inheritdoc}
*/
public function onFieldableEntityTypeUpdate(EntityTypeInterface $entity_type, EntityTypeInterface $original, array $field_storage_definitions, array $original_field_storage_definitions, ?array &$sandbox = NULL) {
$this->onEntityTypeUpdate($entity_type, $original);
}
/**
* {@inheritdoc}
*/
public function onEntityTypeDelete(EntityTypeInterface $entity_type) {
// Nothing to do here.
}
/**
* Adds the 'workspace' revision metadata field to an entity type.
*
* @param \Drupal\Core\Entity\EntityTypeInterface $entity_type
* The entity type that has been installed or updated.
*/
protected function addRevisionMetadataField(EntityTypeInterface $entity_type) {
if (!$entity_type->hasRevisionMetadataKey('workspace')) {
// Bail out if there's an existing field called 'workspace'.
if ($this->entityDefinitionUpdateManager->getFieldStorageDefinition('workspace', $entity_type->id())) {
throw new \RuntimeException("An existing 'workspace' field was found for the '{$entity_type->id()}' entity type. Set the 'workspace' revision metadata key to use a different field name and run this update function again.");
}
// We are only adding a revision metadata key so we don't need to go
// through the entity update process.
$entity_type->setRevisionMetadataKey('workspace', 'workspace');
$this->entityLastInstalledSchemaRepository->setLastInstalledDefinition($entity_type);
}
$this->entityDefinitionUpdateManager->installFieldStorageDefinition($entity_type->getRevisionMetadataKey('workspace'), $entity_type->id(), 'workspaces', $this->getWorkspaceFieldDefinition());
}
/**
* Gets the base field definition for the 'workspace' revision metadata field.
*
* @return \Drupal\Core\Field\BaseFieldDefinition
* The base field definition.
*/
protected function getWorkspaceFieldDefinition() {
return BaseFieldDefinition::create('entity_reference')
->setLabel($this->t('Workspace'))
->setDescription($this->t('Indicates the workspace that this revision belongs to.'))
->setSetting('target_type', 'workspace')
->setInternal(TRUE)
->setTranslatable(FALSE)
->setRevisionable(TRUE);
}
}

View File

@@ -0,0 +1,47 @@
<?php
namespace Drupal\workspaces\EventSubscriber;
use Drupal\Core\Routing\CacheableRouteProviderInterface;
use Drupal\Core\Routing\RouteProviderInterface;
use Drupal\workspaces\WorkspaceManagerInterface;
use Symfony\Component\EventDispatcher\EventSubscriberInterface;
use Symfony\Component\HttpKernel\Event\RequestEvent;
use Symfony\Component\HttpKernel\KernelEvents;
/**
* Provides a event subscriber for setting workspace-specific cache keys.
*
* @internal
*/
class WorkspaceRequestSubscriber implements EventSubscriberInterface {
public function __construct(
protected readonly RouteProviderInterface $routeProvider,
protected readonly WorkspaceManagerInterface $workspaceManager,
) {}
/**
* Adds the active workspace as a cache key part to the route provider.
*
* @param \Symfony\Component\HttpKernel\Event\RequestEvent $event
* An event object.
*/
public function onKernelRequest(RequestEvent $event) {
if ($this->workspaceManager->hasActiveWorkspace() && $this->routeProvider instanceof CacheableRouteProviderInterface) {
$this->routeProvider->addExtraCacheKeyPart('workspace', $this->workspaceManager->getActiveWorkspace()->id());
}
}
/**
* {@inheritdoc}
*/
public static function getSubscribedEvents(): array {
// Use a priority of 33 in order to run before Symfony's router listener.
// @see \Symfony\Component\HttpKernel\EventListener\RouterListener::getSubscribedEvents()
$events[KernelEvents::REQUEST][] = ['onKernelRequest', 33];
return $events;
}
}

View File

@@ -0,0 +1,80 @@
<?php
namespace Drupal\workspaces\Form;
use Drupal\Core\DependencyInjection\ContainerInjectionInterface;
use Drupal\Core\Form\ConfirmFormBase;
use Drupal\Core\Form\FormStateInterface;
use Drupal\Core\Form\WorkspaceSafeFormInterface;
use Drupal\Core\Url;
use Drupal\workspaces\WorkspaceManagerInterface;
use Symfony\Component\DependencyInjection\ContainerInterface;
/**
* Provides a form that switches to the live version of the site.
*/
class SwitchToLiveForm extends ConfirmFormBase implements ContainerInjectionInterface, WorkspaceSafeFormInterface {
/**
* The workspace manager.
*
* @var \Drupal\workspaces\WorkspaceManagerInterface
*/
protected $workspaceManager;
/**
* Constructs a new SwitchToLiveForm.
*
* @param \Drupal\workspaces\WorkspaceManagerInterface $workspace_manager
* The workspace manager.
*/
public function __construct(WorkspaceManagerInterface $workspace_manager) {
$this->workspaceManager = $workspace_manager;
}
/**
* {@inheritdoc}
*/
public static function create(ContainerInterface $container) {
return new static(
$container->get('workspaces.manager')
);
}
/**
* {@inheritdoc}
*/
public function getFormId() {
return 'switch_to_live_form';
}
/**
* {@inheritdoc}
*/
public function getQuestion() {
return $this->t('Would you like to switch to the live version of the site?');
}
/**
* {@inheritdoc}
*/
public function getDescription() {
return $this->t('Switch to the live version of the site.');
}
/**
* {@inheritdoc}
*/
public function getCancelUrl() {
return new Url('<current>');
}
/**
* {@inheritdoc}
*/
public function submitForm(array &$form, FormStateInterface $form_state) {
$this->workspaceManager->switchToLive();
$this->messenger()->addMessage($this->t('You are now viewing the live version of the site.'));
}
}

View File

@@ -0,0 +1,144 @@
<?php
namespace Drupal\workspaces\Form;
use Drupal\Core\Access\AccessResult;
use Drupal\Core\Entity\EntityConfirmFormBase;
use Drupal\Core\Form\FormStateInterface;
use Drupal\Core\Form\WorkspaceSafeFormInterface;
use Drupal\Core\Messenger\MessengerInterface;
use Drupal\Core\Routing\RouteMatchInterface;
use Drupal\workspaces\WorkspaceAccessException;
use Drupal\workspaces\WorkspaceManagerInterface;
use Symfony\Component\DependencyInjection\ContainerInterface;
/**
* Handle activation of a workspace on administrative pages.
*/
class WorkspaceActivateForm extends EntityConfirmFormBase implements WorkspaceSafeFormInterface {
/**
* The workspace entity.
*
* @var \Drupal\workspaces\WorkspaceInterface
*/
protected $entity;
/**
* The workspace replication manager.
*
* @var \Drupal\workspaces\WorkspaceManagerInterface
*/
protected $workspaceManager;
/**
* The messenger service.
*
* @var \Drupal\Core\Messenger\MessengerInterface
*/
protected $messenger;
/**
* Constructs a new WorkspaceActivateForm.
*
* @param \Drupal\workspaces\WorkspaceManagerInterface $workspace_manager
* The workspace manager.
* @param \Drupal\Core\Messenger\MessengerInterface $messenger
* The messenger service.
*/
public function __construct(WorkspaceManagerInterface $workspace_manager, MessengerInterface $messenger) {
$this->workspaceManager = $workspace_manager;
$this->messenger = $messenger;
}
/**
* {@inheritdoc}
*/
public static function create(ContainerInterface $container) {
return new static(
$container->get('workspaces.manager'),
$container->get('messenger')
);
}
/**
* {@inheritdoc}
*/
public function getQuestion() {
return $this->t('Would you like to activate the %workspace workspace?', ['%workspace' => $this->entity->label()]);
}
/**
* {@inheritdoc}
*/
public function getDescription() {
return $this->t('Activate the %workspace workspace.', ['%workspace' => $this->entity->label()]);
}
/**
* {@inheritdoc}
*/
public function getCancelUrl() {
return $this->entity->toUrl('collection');
}
/**
* {@inheritdoc}
*/
public function buildForm(array $form, FormStateInterface $form_state) {
$form = parent::buildForm($form, $form_state);
// Content entity forms do not use the parent's #after_build callback.
unset($form['#after_build']);
return $form;
}
/**
* {@inheritdoc}
*/
public function actions(array $form, FormStateInterface $form_state) {
$actions = parent::actions($form, $form_state);
$actions['cancel']['#attributes']['class'][] = 'dialog-cancel';
return $actions;
}
/**
* {@inheritdoc}
*/
public function submitForm(array &$form, FormStateInterface $form_state) {
try {
$this->workspaceManager->setActiveWorkspace($this->entity);
$this->messenger->addMessage($this->t('%workspace_label is now the active workspace.', ['%workspace_label' => $this->entity->label()]));
}
catch (WorkspaceAccessException $e) {
$this->messenger->addError($this->t('You do not have access to activate the %workspace_label workspace.', ['%workspace_label' => $this->entity->label()]));
}
// Redirect to the workspace manage page by default.
if (!$this->getRequest()->query->has('destination')) {
$form_state->setRedirectUrl($this->entity->toUrl());
}
}
/**
* Checks access for the workspace activate form.
*
* @param \Drupal\Core\Routing\RouteMatchInterface $route_match
* The route match.
*
* @return \Drupal\Core\Access\AccessResult
* The access result.
*/
public function checkAccess(RouteMatchInterface $route_match) {
/** @var \Drupal\workspaces\WorkspaceInterface $workspace */
$workspace = $route_match->getParameter('workspace');
$active_workspace = $this->workspaceManager->getActiveWorkspace();
$access = AccessResult::allowedIf(!$active_workspace || ($active_workspace && $active_workspace->id() != $workspace->id()))
->addCacheableDependency($workspace);
return $access;
}
}

View File

@@ -0,0 +1,115 @@
<?php
namespace Drupal\workspaces\Form;
use Drupal\Component\Datetime\TimeInterface;
use Drupal\Core\Entity\ContentEntityDeleteForm;
use Drupal\Core\Entity\EntityRepositoryInterface;
use Drupal\Core\Entity\EntityTypeBundleInfoInterface;
use Drupal\Core\Form\FormStateInterface;
use Drupal\workspaces\WorkspaceAssociationInterface;
use Drupal\workspaces\WorkspaceRepositoryInterface;
use Symfony\Component\DependencyInjection\ContainerInterface;
/**
* Provides a form for deleting a workspace.
*
* @internal
*/
class WorkspaceDeleteForm extends ContentEntityDeleteForm {
/**
* The workspace entity.
*
* @var \Drupal\workspaces\WorkspaceInterface
*/
protected $entity;
/**
* The workspace association service.
*
* @var \Drupal\workspaces\WorkspaceAssociationInterface
*/
protected $workspaceAssociation;
/**
* The workspace repository service.
*
* @var \Drupal\workspaces\WorkspaceRepositoryInterface
*/
protected $workspaceRepository;
/**
* {@inheritdoc}
*/
public static function create(ContainerInterface $container) {
return new static(
$container->get('entity.repository'),
$container->get('workspaces.association'),
$container->get('workspaces.repository'),
$container->get('entity_type.bundle.info'),
$container->get('datetime.time')
);
}
/**
* Constructs a WorkspaceDeleteForm object.
*
* @param \Drupal\Core\Entity\EntityRepositoryInterface $entity_repository
* The entity repository service.
* @param \Drupal\workspaces\WorkspaceAssociationInterface $workspace_association
* The workspace association service to check how many revisions will be
* deleted.
* @param \Drupal\workspaces\WorkspaceRepositoryInterface $workspace_repository
* The workspace repository service.
* @param \Drupal\Core\Entity\EntityTypeBundleInfoInterface $entity_type_bundle_info
* The entity type bundle service.
* @param \Drupal\Component\Datetime\TimeInterface $time
* The time service.
*/
public function __construct(EntityRepositoryInterface $entity_repository, WorkspaceAssociationInterface $workspace_association, WorkspaceRepositoryInterface $workspace_repository, ?EntityTypeBundleInfoInterface $entity_type_bundle_info = NULL, ?TimeInterface $time = NULL) {
parent::__construct($entity_repository, $entity_type_bundle_info, $time);
$this->workspaceAssociation = $workspace_association;
$this->workspaceRepository = $workspace_repository;
}
/**
* {@inheritdoc}
*/
public function buildForm(array $form, FormStateInterface $form_state) {
$form = parent::buildForm($form, $form_state);
$workspace_tree = $this->workspaceRepository->loadTree();
if (!empty($workspace_tree[$this->entity->id()]['descendants'])) {
$form['description']['#markup'] = $this->t('The %label workspace can not be deleted because it has child workspaces.', [
'%label' => $this->entity->label(),
]);
$form['actions']['submit']['#disabled'] = TRUE;
return $form;
}
$tracked_entities = $this->workspaceAssociation->getTrackedEntities($this->entity->id());
$items = [];
foreach ($tracked_entities as $entity_type_id => $entity_ids) {
$revision_ids = $this->workspaceAssociation->getAssociatedRevisions($this->entity->id(), $entity_type_id, $entity_ids);
$label = $this->entityTypeManager->getDefinition($entity_type_id)->getLabel();
$items[] = $this->formatPlural(count($revision_ids), '1 @label revision.', '@count @label revisions.', ['@label' => $label]);
}
$form['revisions'] = [
'#theme' => 'item_list',
'#title' => $this->t('The following will also be deleted:'),
'#items' => $items,
];
return $form;
}
/**
* {@inheritdoc}
*/
public function getDescription() {
return $this->t('This action cannot be undone, and will also delete all content created in this workspace.');
}
}

View File

@@ -0,0 +1,169 @@
<?php
namespace Drupal\workspaces\Form;
use Drupal\Core\Entity\ContentEntityForm;
use Drupal\Core\Entity\EntityConstraintViolationListInterface;
use Drupal\Core\Form\FormStateInterface;
use Drupal\Core\Url;
use Drupal\workspaces\WorkspaceManagerInterface;
use Symfony\Component\DependencyInjection\ContainerInterface;
/**
* Form controller for the workspace edit forms.
*/
class WorkspaceForm extends ContentEntityForm {
/**
* The workspace entity.
*
* @var \Drupal\workspaces\WorkspaceInterface
*/
protected $entity;
/**
* The workspace manager.
*/
protected WorkspaceManagerInterface $workspaceManager;
/**
* {@inheritdoc}
*/
public static function create(ContainerInterface $container) {
$instance = parent::create($container);
$instance->workspaceManager = $container->get('workspaces.manager');
return $instance;
}
/**
* {@inheritdoc}
*/
public function form(array $form, FormStateInterface $form_state) {
$workspace = $this->entity;
if ($this->operation == 'edit') {
$form['#title'] = $this->t('Edit workspace %label', ['%label' => $workspace->label()]);
}
$form['label'] = [
'#type' => 'textfield',
'#title' => $this->t('Label'),
'#maxlength' => 255,
'#default_value' => $workspace->label(),
'#required' => TRUE,
];
$form['id'] = [
'#type' => 'machine_name',
'#title' => $this->t('Workspace ID'),
'#maxlength' => 255,
'#default_value' => $workspace->id(),
'#disabled' => !$workspace->isNew(),
'#machine_name' => [
'exists' => '\Drupal\workspaces\Entity\Workspace::load',
],
'#element_validate' => [],
];
return parent::form($form, $form_state);
}
/**
* {@inheritdoc}
*/
protected function getEditedFieldNames(FormStateInterface $form_state) {
return array_merge([
'label',
'id',
], parent::getEditedFieldNames($form_state));
}
/**
* {@inheritdoc}
*/
protected function flagViolations(EntityConstraintViolationListInterface $violations, array $form, FormStateInterface $form_state) {
// Manually flag violations of fields not handled by the form display. This
// is necessary as entity form displays only flag violations for fields
// contained in the display.
$field_names = [
'label',
'id',
];
foreach ($violations->getByFields($field_names) as $violation) {
[$field_name] = explode('.', $violation->getPropertyPath(), 2);
$form_state->setErrorByName($field_name, $violation->getMessage());
}
parent::flagViolations($violations, $form, $form_state);
}
/**
* {@inheritdoc}
*/
protected function actions(array $form, FormStateInterface $form_state): array {
$actions = parent::actions($form, $form_state);
// When adding a new workspace, the default action should also activate it.
if ($this->entity->isNew()) {
$actions['submit']['#value'] = $this->t('Save and switch');
$actions['submit']['#submit'] = ['::submitForm', '::save', '::activate'];
$actions['save'] = [
'#type' => 'submit',
'#value' => $this->t('Save'),
'#submit' => ['::submitForm', '::save'],
];
}
return $actions;
}
/**
* {@inheritdoc}
*/
public function save(array $form, FormStateInterface $form_state) {
$workspace = $this->entity;
$workspace->setNewRevision(TRUE);
$status = $workspace->save();
$info = ['%info' => $workspace->label()];
$context = ['@type' => $workspace->bundle(), '%info' => $workspace->label()];
$logger = $this->logger('workspaces');
if ($status == SAVED_UPDATED) {
$logger->notice('@type: updated %info.', $context);
$this->messenger()->addMessage($this->t('Workspace %info has been updated.', $info));
}
else {
$logger->notice('@type: added %info.', $context);
$this->messenger()->addMessage($this->t('Workspace %info has been created.', $info));
}
if ($workspace->id()) {
$form_state->setValue('id', $workspace->id());
$form_state->set('id', $workspace->id());
$collection_url = $workspace->toUrl('collection');
$redirect = $collection_url->access() ? $collection_url : Url::fromRoute('<front>');
$form_state->setRedirectUrl($redirect);
}
else {
$this->messenger()->addError($this->t('The workspace could not be saved.'));
$form_state->setRebuild();
}
}
/**
* Form submission handler for the 'submit' action.
*
* @param array $form
* An associative array containing the structure of the form.
* @param \Drupal\Core\Form\FormStateInterface $form_state
* The current state of the form.
*/
public function activate(array $form, FormStateInterface $form_state): void {
$this->workspaceManager->setActiveWorkspace($this->entity);
$this->messenger()->addMessage($this->t('%label is now the active workspace.', [
'%label' => $this->entity->label(),
]));
}
}

View File

@@ -0,0 +1,18 @@
<?php
namespace Drupal\workspaces\Form;
use Drupal\Core\Form\FormInterface;
/**
* Defines interface for workspace forms so they can be easily distinguished.
*
* @internal
*
* @deprecated in drupal:10.3.0 and is removed from drupal:11.0.0. Use
* \Drupal\Core\Form\WorkspaceSafeFormInterface or
* \Drupal\Core\Form\WorkspaceDynamicSafeFormInterface instead.
*
* @see https://www.drupal.org/node/3229111
*/
interface WorkspaceFormInterface extends FormInterface {}

View File

@@ -0,0 +1,154 @@
<?php
namespace Drupal\workspaces\Form;
use Drupal\Core\DependencyInjection\ContainerInjectionInterface;
use Drupal\Core\Entity\EntityTypeManagerInterface;
use Drupal\Core\Form\ConfirmFormBase;
use Drupal\Core\Form\FormStateInterface;
use Drupal\Core\Form\WorkspaceSafeFormInterface;
use Drupal\Core\Url;
use Drupal\workspaces\WorkspaceInterface;
use Drupal\workspaces\WorkspaceOperationFactory;
use Symfony\Component\DependencyInjection\ContainerInterface;
/**
* Provides a form that merges the contents for a workspace into another one.
*/
class WorkspaceMergeForm extends ConfirmFormBase implements ContainerInjectionInterface, WorkspaceSafeFormInterface {
/**
* The source workspace entity.
*
* @var \Drupal\workspaces\WorkspaceInterface
*/
protected $sourceWorkspace;
/**
* The target workspace entity.
*
* @var \Drupal\workspaces\WorkspaceInterface
*/
protected $targetWorkspace;
/**
* The workspace operation factory.
*
* @var \Drupal\workspaces\WorkspaceOperationFactory
*/
protected $workspaceOperationFactory;
/**
* The entity type manager.
*
* @var \Drupal\Core\Entity\EntityTypeManagerInterface
*/
protected $entityTypeManager;
/**
* Constructs a new WorkspaceMergeForm.
*
* @param \Drupal\workspaces\WorkspaceOperationFactory $workspace_operation_factory
* The workspace operation factory service.
* @param \Drupal\Core\Entity\EntityTypeManagerInterface $entity_type_manager
* The entity type manager.
*/
public function __construct(WorkspaceOperationFactory $workspace_operation_factory, EntityTypeManagerInterface $entity_type_manager) {
$this->workspaceOperationFactory = $workspace_operation_factory;
$this->entityTypeManager = $entity_type_manager;
}
/**
* {@inheritdoc}
*/
public static function create(ContainerInterface $container) {
return new static(
$container->get('workspaces.operation_factory'),
$container->get('entity_type.manager')
);
}
/**
* {@inheritdoc}
*/
public function getFormId() {
return 'workspace_merge_form';
}
/**
* {@inheritdoc}
*/
public function buildForm(array $form, FormStateInterface $form_state, ?WorkspaceInterface $source_workspace = NULL, ?WorkspaceInterface $target_workspace = NULL) {
$this->sourceWorkspace = $source_workspace;
$this->targetWorkspace = $target_workspace;
$form = parent::buildForm($form, $form_state);
$workspace_merger = $this->workspaceOperationFactory->getMerger($this->sourceWorkspace, $this->targetWorkspace);
$args = [
'%source_label' => $this->sourceWorkspace->label(),
'%target_label' => $this->targetWorkspace->label(),
];
// List the changes that can be merged into the target.
if ($source_rev_diff = $workspace_merger->getDifferringRevisionIdsOnSource()) {
$total_count = $workspace_merger->getNumberOfChangesOnSource();
$form['merge'] = [
'#theme' => 'item_list',
'#title' => $this->formatPlural($total_count, 'There is @count item that can be merged from %source_label to %target_label', 'There are @count items that can be merged from %source_label to %target_label', $args),
'#items' => [],
'#total_count' => $total_count,
];
foreach ($source_rev_diff as $entity_type_id => $revision_difference) {
$form['merge']['#items'][$entity_type_id] = $this->entityTypeManager->getDefinition($entity_type_id)->getCountLabel(count($revision_difference));
}
}
// If there are no changes to merge, show an informational message.
if (!isset($form['merge'])) {
$form['description'] = [
'#markup' => $this->t('There are no changes that can be merged from %source_label to %target_label.', $args),
];
$form['actions']['submit']['#disabled'] = TRUE;
}
return $form;
}
/**
* {@inheritdoc}
*/
public function getQuestion() {
return $this->t('Would you like to merge the contents of the %source_label workspace into %target_label?', [
'%source_label' => $this->sourceWorkspace->label(),
'%target_label' => $this->targetWorkspace->label(),
]);
}
/**
* {@inheritdoc}
*/
public function getDescription() {
return $this->t('Merge workspace contents.');
}
/**
* {@inheritdoc}
*/
public function getCancelUrl() {
return Url::fromRoute('entity.workspace.collection', [], ['query' => $this->getDestinationArray()]);
}
/**
* {@inheritdoc}
*/
public function submitForm(array &$form, FormStateInterface $form_state) {
$this->workspaceOperationFactory->getMerger($this->sourceWorkspace, $this->targetWorkspace)->merge();
$this->messenger()->addMessage($this->t('The contents of the %source_label workspace have been merged into %target_label.', [
'%source_label' => $this->sourceWorkspace->label(),
'%target_label' => $this->targetWorkspace->label(),
]));
}
}

View File

@@ -0,0 +1,159 @@
<?php
namespace Drupal\workspaces\Form;
use Drupal\Core\DependencyInjection\ContainerInjectionInterface;
use Drupal\Core\Entity\EntityTypeManagerInterface;
use Drupal\Core\Form\ConfirmFormBase;
use Drupal\Core\Form\FormStateInterface;
use Drupal\Core\Form\WorkspaceSafeFormInterface;
use Drupal\Core\Url;
use Drupal\workspaces\WorkspaceAccessException;
use Drupal\workspaces\WorkspaceInterface;
use Drupal\workspaces\WorkspaceOperationFactory;
use Symfony\Component\DependencyInjection\ContainerInterface;
/**
* Provides the workspace publishing form.
*/
class WorkspacePublishForm extends ConfirmFormBase implements ContainerInjectionInterface, WorkspaceSafeFormInterface {
/**
* The workspace that will be published.
*
* @var \Drupal\workspaces\WorkspaceInterface
*/
protected $workspace;
/**
* The workspace operation factory.
*
* @var \Drupal\workspaces\WorkspaceOperationFactory
*/
protected $workspaceOperationFactory;
/**
* The entity type manager.
*
* @var \Drupal\Core\Entity\EntityTypeManagerInterface
*/
protected $entityTypeManager;
/**
* Constructs a new WorkspacePublishForm.
*
* @param \Drupal\workspaces\WorkspaceOperationFactory $workspace_operation_factory
* The workspace operation factory service.
* @param \Drupal\Core\Entity\EntityTypeManagerInterface $entity_type_manager
* The entity type manager.
*/
public function __construct(WorkspaceOperationFactory $workspace_operation_factory, EntityTypeManagerInterface $entity_type_manager) {
$this->workspaceOperationFactory = $workspace_operation_factory;
$this->entityTypeManager = $entity_type_manager;
}
/**
* {@inheritdoc}
*/
public static function create(ContainerInterface $container) {
return new static(
$container->get('workspaces.operation_factory'),
$container->get('entity_type.manager')
);
}
/**
* {@inheritdoc}
*/
public function getFormId() {
return 'workspace_publish_form';
}
/**
* {@inheritdoc}
*/
public function buildForm(array $form, FormStateInterface $form_state, ?WorkspaceInterface $workspace = NULL) {
$this->workspace = $workspace;
$form = parent::buildForm($form, $form_state);
$workspace_publisher = $this->workspaceOperationFactory->getPublisher($this->workspace);
$args = [
'%source_label' => $this->workspace->label(),
'%target_label' => $workspace_publisher->getTargetLabel(),
];
$form['#title'] = $this->t('Publish %source_label workspace', $args);
// List the changes that can be pushed.
if ($source_rev_diff = $workspace_publisher->getDifferringRevisionIdsOnSource()) {
$total_count = $workspace_publisher->getNumberOfChangesOnSource();
$form['description'] = [
'#theme' => 'item_list',
'#title' => $this->formatPlural($total_count, 'There is @count item that can be published from %source_label to %target_label', 'There are @count items that can be published from %source_label to %target_label', $args),
'#items' => [],
'#total_count' => $total_count,
];
foreach ($source_rev_diff as $entity_type_id => $revision_difference) {
$form['description']['#items'][$entity_type_id] = $this->entityTypeManager->getDefinition($entity_type_id)->getCountLabel(count($revision_difference));
}
$form['actions']['submit']['#value'] = $this->formatPlural($total_count, 'Publish @count item to @target', 'Publish @count items to @target', ['@target' => $workspace_publisher->getTargetLabel()]);
}
else {
// If there are no changes to push or pull, show an informational message.
$form['help'] = [
'#markup' => $this->t('There are no changes that can be published from %source_label to %target_label.', $args),
];
// Do not allow the 'Publish' operation if there's nothing to publish.
$form['actions']['submit']['#value'] = $this->t('Publish');
$form['actions']['submit']['#disabled'] = TRUE;
}
return $form;
}
/**
* {@inheritdoc}
*/
public function getQuestion() {
return $this->t('Would you like to publish the contents of the %label workspace?', [
'%label' => $this->workspace->label(),
]);
}
/**
* {@inheritdoc}
*/
public function getDescription() {
return $this->t('Publish workspace contents.');
}
/**
* {@inheritdoc}
*/
public function getCancelUrl() {
return Url::fromRoute('entity.workspace.collection', [], ['query' => $this->getDestinationArray()]);
}
/**
* {@inheritdoc}
*/
public function submitForm(array &$form, FormStateInterface $form_state) {
$workspace = $this->workspace;
try {
$workspace->publish();
$this->messenger()->addMessage($this->t('Successful publication.'));
}
catch (WorkspaceAccessException $e) {
$this->messenger()->addMessage($e->getMessage(), 'error');
}
catch (\Exception $e) {
$this->messenger()->addMessage($this->t('Publication failed. All errors have been logged.'), 'error');
$this->getLogger('workspaces')->error($e->getMessage());
}
}
}

View File

@@ -0,0 +1,156 @@
<?php
namespace Drupal\workspaces\Form;
use Drupal\Core\Entity\EntityTypeManagerInterface;
use Drupal\Core\Form\FormBase;
use Drupal\Core\Form\FormStateInterface;
use Drupal\Core\Form\WorkspaceSafeFormInterface;
use Drupal\Core\Messenger\MessengerInterface;
use Drupal\workspaces\WorkspaceAccessException;
use Drupal\workspaces\WorkspaceManagerInterface;
use Symfony\Component\DependencyInjection\ContainerInterface;
/**
* Provides a form that activates a different workspace.
*/
class WorkspaceSwitcherForm extends FormBase implements WorkspaceSafeFormInterface {
/**
* The workspace manager.
*
* @var \Drupal\workspaces\WorkspaceManagerInterface
*/
protected $workspaceManager;
/**
* The workspace entity storage handler.
*
* @var \Drupal\Core\Entity\EntityStorageInterface
*/
protected $workspaceStorage;
/**
* The messenger service.
*
* @var \Drupal\Core\Messenger\MessengerInterface
*/
protected $messenger;
/**
* Constructs a new WorkspaceSwitcherForm.
*
* @param \Drupal\workspaces\WorkspaceManagerInterface $workspace_manager
* The workspace manager.
* @param \Drupal\Core\Entity\EntityTypeManagerInterface $entity_type_manager
* The entity type manager.
* @param \Drupal\Core\Messenger\MessengerInterface $messenger
* The messenger service.
*/
public function __construct(WorkspaceManagerInterface $workspace_manager, EntityTypeManagerInterface $entity_type_manager, MessengerInterface $messenger) {
$this->workspaceManager = $workspace_manager;
$this->workspaceStorage = $entity_type_manager->getStorage('workspace');
$this->messenger = $messenger;
}
/**
* {@inheritdoc}
*/
public static function create(ContainerInterface $container) {
return new static(
$container->get('workspaces.manager'),
$container->get('entity_type.manager'),
$container->get('messenger')
);
}
/**
* {@inheritdoc}
*/
public function getFormId() {
return 'workspace_switcher_form';
}
/**
* {@inheritdoc}
*/
public function buildForm(array $form, FormStateInterface $form_state) {
$workspaces = $this->workspaceStorage->loadMultiple();
$workspace_labels = [];
foreach ($workspaces as $workspace) {
$workspace_labels[$workspace->id()] = $workspace->label();
}
$active_workspace = $this->workspaceManager->getActiveWorkspace();
if ($active_workspace) {
unset($workspace_labels[$active_workspace->id()]);
}
$form['current'] = [
'#type' => 'item',
'#title' => $this->t('Current workspace'),
'#markup' => $active_workspace ? $active_workspace->label() : $this->t('None'),
'#wrapper_attributes' => [
'class' => ['container-inline'],
],
];
$form['workspace_id'] = [
'#type' => 'select',
'#title' => $this->t('Select workspace'),
'#required' => TRUE,
'#options' => $workspace_labels,
'#wrapper_attributes' => [
'class' => ['container-inline'],
],
'#access' => !empty($workspace_labels),
];
$form['actions']['#type'] = 'actions';
$form['actions']['submit'] = [
'#type' => 'submit',
'#value' => $this->t('Activate'),
'#button_type' => 'primary',
'#access' => !empty($workspace_labels),
];
if ($active_workspace) {
$form['actions']['switch_to_live'] = [
'#type' => 'submit',
'#submit' => ['::submitSwitchToLive'],
'#value' => $this->t('Switch to Live'),
'#limit_validation_errors' => [],
'#button_type' => 'primary',
];
}
return $form;
}
/**
* {@inheritdoc}
*/
public function submitForm(array &$form, FormStateInterface $form_state) {
$id = $form_state->getValue('workspace_id');
/** @var \Drupal\workspaces\WorkspaceInterface $workspace */
$workspace = $this->workspaceStorage->load($id);
try {
$this->workspaceManager->setActiveWorkspace($workspace);
$this->messenger->addMessage($this->t('%workspace_label is now the active workspace.', ['%workspace_label' => $workspace->label()]));
}
catch (WorkspaceAccessException $e) {
$this->messenger->addError($this->t('You do not have access to activate the %workspace_label workspace.', ['%workspace_label' => $workspace->label()]));
}
}
/**
* Submit handler for switching to the live version of the site.
*/
public function submitSwitchToLive(array &$form, FormStateInterface $form_state) {
$this->workspaceManager->switchToLive();
$this->messenger->addMessage($this->t('You are now viewing the live version of the site.'));
}
}

View File

@@ -0,0 +1,108 @@
<?php
namespace Drupal\workspaces;
use Drupal\Core\DependencyInjection\ContainerInjectionInterface;
use Drupal\Core\Form\FormStateInterface;
use Drupal\Core\Form\WorkspaceDynamicSafeFormInterface;
use Drupal\Core\Form\WorkspaceSafeFormInterface;
use Drupal\Core\Render\Element;
use Drupal\Core\StringTranslation\TranslatableMarkup;
use Symfony\Component\DependencyInjection\ContainerInterface;
/**
* Defines a class for reacting to form operations.
*
* @internal
*/
class FormOperations implements ContainerInjectionInterface {
/**
* The workspace manager service.
*
* @var \Drupal\workspaces\WorkspaceManagerInterface
*/
protected $workspaceManager;
/**
* Constructs a new FormOperations instance.
*
* @param \Drupal\workspaces\WorkspaceManagerInterface $workspace_manager
* The workspace manager service.
*/
public function __construct(WorkspaceManagerInterface $workspace_manager) {
$this->workspaceManager = $workspace_manager;
}
/**
* {@inheritdoc}
*/
public static function create(ContainerInterface $container) {
return new static(
$container->get('workspaces.manager')
);
}
/**
* Alters forms to disallow editing in non-default workspaces.
*
* @param array $form
* An associative array containing the structure of the form.
* @param \Drupal\Core\Form\FormStateInterface $form_state
* The current state of the form.
* @param string $form_id
* The form ID.
*
* @see hook_form_alter()
*/
public function formAlter(array &$form, FormStateInterface $form_state, $form_id) {
// No alterations are needed if we're not in a workspace context.
if (!$this->workspaceManager->hasActiveWorkspace()) {
return;
}
// Add a validation step for every form if we are in a workspace.
$this->addWorkspaceValidation($form);
// If a form has already been marked as safe or not to submit in a
// workspace, we don't have anything else to do.
if ($form_state->has('workspace_safe')) {
return;
}
$form_object = $form_state->getFormObject();
$workspace_safe = $form_object instanceof WorkspaceSafeFormInterface
|| ($form_object instanceof WorkspaceDynamicSafeFormInterface && $form_object->isWorkspaceSafeForm($form, $form_state));
$form_state->set('workspace_safe', $workspace_safe);
}
/**
* Adds our validation handler recursively on each element of a form.
*
* @param array &$element
* An associative array containing the structure of the form.
*/
protected function addWorkspaceValidation(array &$element) {
// Recurse through all children and add our validation handler if needed.
foreach (Element::children($element) as $key) {
if (isset($element[$key]) && $element[$key]) {
$this->addWorkspaceValidation($element[$key]);
}
}
if (isset($element['#validate'])) {
$element['#validate'][] = [static::class, 'validateDefaultWorkspace'];
}
}
/**
* Validation handler which sets a validation error for all unsupported forms.
*/
public static function validateDefaultWorkspace(array &$form, FormStateInterface $form_state) {
if ($form_state->get('workspace_safe') !== TRUE) {
$form_state->setError($form, new TranslatableMarkup('This form can only be submitted in the default workspace.'));
}
}
}

View File

@@ -0,0 +1,52 @@
<?php
namespace Drupal\workspaces\Negotiator;
use Drupal\Component\Utility\Crypt;
use Drupal\Core\Site\Settings;
use Symfony\Component\HttpFoundation\Request;
/**
* Defines the query parameter workspace negotiator.
*/
class QueryParameterWorkspaceNegotiator extends SessionWorkspaceNegotiator {
/**
* {@inheritdoc}
*/
public function applies(Request $request) {
return is_string($request->query->get('workspace'))
&& is_string($request->query->get('token'))
&& parent::applies($request);
}
/**
* {@inheritdoc}
*/
public function getActiveWorkspaceId(Request $request): ?string {
$workspace_id = (string) $request->query->get('workspace');
$token = (string) $request->query->get('token');
$is_valid_token = hash_equals($this->getQueryToken($workspace_id), $token);
// This negotiator receives a workspace ID from user input, so a minimal
// validation is needed to ensure that we protect against fake input before
// the workspace manager fully validates the negotiated workspace ID.
// @see \Drupal\workspaces\WorkspaceManager::getActiveWorkspace()
return $is_valid_token ? $workspace_id : NULL;
}
/**
* Calculates a token based on a workspace ID.
*
* @param string $workspace_id
* The workspace ID.
*
* @return string
* An 8 char token based on the given workspace ID.
*/
protected function getQueryToken(string $workspace_id): string {
// Return the first 8 characters.
return substr(Crypt::hmacBase64($workspace_id, Settings::getHashSalt()), 0, 8);
}
}

View File

@@ -0,0 +1,64 @@
<?php
namespace Drupal\workspaces\Negotiator;
use Drupal\Core\Entity\EntityTypeManagerInterface;
use Drupal\Core\Session\AccountInterface;
use Drupal\workspaces\WorkspaceInterface;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Session\Session;
/**
* Defines the session workspace negotiator.
*/
class SessionWorkspaceNegotiator implements WorkspaceNegotiatorInterface, WorkspaceIdNegotiatorInterface {
public function __construct(
protected readonly AccountInterface $currentUser,
protected readonly Session $session,
protected readonly EntityTypeManagerInterface $entityTypeManager,
) {}
/**
* {@inheritdoc}
*/
public function applies(Request $request) {
// This negotiator only applies if the current user is authenticated.
return $this->currentUser->isAuthenticated();
}
/**
* {@inheritdoc}
*/
public function getActiveWorkspaceId(Request $request): ?string {
return $this->session->get('active_workspace_id');
}
/**
* {@inheritdoc}
*/
public function getActiveWorkspace(Request $request) {
$workspace_id = $this->getActiveWorkspaceId($request);
if ($workspace_id && ($workspace = $this->entityTypeManager->getStorage('workspace')->load($workspace_id))) {
return $workspace;
}
return NULL;
}
/**
* {@inheritdoc}
*/
public function setActiveWorkspace(WorkspaceInterface $workspace) {
$this->session->set('active_workspace_id', $workspace->id());
}
/**
* {@inheritdoc}
*/
public function unsetActiveWorkspace() {
$this->session->remove('active_workspace_id');
}
}

View File

@@ -0,0 +1,23 @@
<?php
namespace Drupal\workspaces\Negotiator;
use Symfony\Component\HttpFoundation\Request;
/**
* Interface for workspace negotiators that return only the negotiated ID.
*/
interface WorkspaceIdNegotiatorInterface {
/**
* Performs workspace negotiation.
*
* @param \Symfony\Component\HttpFoundation\Request $request
* The HTTP request.
*
* @return string|null
* A valid workspace ID if the negotiation was successful, NULL otherwise.
*/
public function getActiveWorkspaceId(Request $request): ?string;
}

View File

@@ -0,0 +1,40 @@
<?php
namespace Drupal\workspaces\Negotiator;
use Drupal\workspaces\WorkspaceInterface;
use Symfony\Component\HttpFoundation\Request;
/**
* Workspace negotiators provide a way to get the active workspace.
*
* \Drupal\workspaces\WorkspaceManager acts as the service collector for
* Workspace negotiators.
*/
interface WorkspaceNegotiatorInterface {
/**
* Checks whether the negotiator applies to the current request or not.
*
* @param \Symfony\Component\HttpFoundation\Request $request
* The HTTP request.
*
* @return bool
* TRUE if the negotiator applies for the current request, FALSE otherwise.
*/
public function applies(Request $request);
/**
* Notifies the negotiator that the workspace ID returned has been accepted.
*
* @param \Drupal\workspaces\WorkspaceInterface $workspace
* The negotiated workspace entity.
*/
public function setActiveWorkspace(WorkspaceInterface $workspace);
/**
* Unsets the negotiated workspace.
*/
public function unsetActiveWorkspace();
}

View File

@@ -0,0 +1,99 @@
<?php
namespace Drupal\workspaces\Plugin\Block;
use Drupal\Core\Access\AccessResult;
use Drupal\Core\Access\AccessResultInterface;
use Drupal\Core\Block\Attribute\Block;
use Drupal\Core\Block\BlockBase;
use Drupal\Core\Entity\EntityTypeManagerInterface;
use Drupal\Core\Form\FormBuilderInterface;
use Drupal\Core\Plugin\ContainerFactoryPluginInterface;
use Drupal\Core\Session\AccountInterface;
use Drupal\Core\StringTranslation\TranslatableMarkup;
use Drupal\workspaces\Form\WorkspaceSwitcherForm;
use Symfony\Component\DependencyInjection\ContainerInterface;
/**
* Provides a 'Workspace switcher' block.
*/
#[Block(
id: "workspace_switcher",
admin_label: new TranslatableMarkup("Workspace switcher"),
category: new TranslatableMarkup("Workspace")
)]
class WorkspaceSwitcherBlock extends BlockBase implements ContainerFactoryPluginInterface {
/**
* The form builder.
*
* @var \Drupal\Core\Form\FormBuilderInterface
*/
protected $formBuilder;
/**
* The entity type manager.
*
* @var \Drupal\Core\Entity\EntityTypeManagerInterface
*/
protected $entityTypeManager;
/**
* Constructs a new WorkspaceSwitcherBlock instance.
*
* @param array $configuration
* The plugin configuration.
* @param string $plugin_id
* The plugin ID.
* @param mixed $plugin_definition
* The plugin definition.
* @param \Drupal\Core\Form\FormBuilderInterface $form_builder
* The form builder.
* @param \Drupal\Core\Entity\EntityTypeManagerInterface $entity_type_manager
* The entity type manager.
*/
public function __construct(array $configuration, $plugin_id, $plugin_definition, FormBuilderInterface $form_builder, EntityTypeManagerInterface $entity_type_manager) {
parent::__construct($configuration, $plugin_id, $plugin_definition);
$this->formBuilder = $form_builder;
$this->entityTypeManager = $entity_type_manager;
}
/**
* {@inheritdoc}
*/
public static function create(ContainerInterface $container, array $configuration, $plugin_id, $plugin_definition) {
return new static(
$configuration,
$plugin_id,
$plugin_definition,
$container->get('form_builder'),
$container->get('entity_type.manager')
);
}
/**
* {@inheritdoc}
*/
public function build() {
$build = [
'form' => $this->formBuilder->getForm(WorkspaceSwitcherForm::class),
'#cache' => [
'contexts' => $this->entityTypeManager->getDefinition('workspace')->getListCacheContexts(),
'tags' => $this->entityTypeManager->getDefinition('workspace')->getListCacheTags(),
],
];
return $build;
}
/**
* {@inheritdoc}
*/
protected function blockAccess(AccountInterface $account): AccessResultInterface {
return AccessResult::allowedIfHasPermissions($account, [
'view own workspace',
'view any workspace',
'administer workspaces',
], 'OR');
}
}

View File

@@ -0,0 +1,103 @@
<?php
namespace Drupal\workspaces\Plugin\EntityReferenceSelection;
use Drupal\Component\Utility\Html;
use Drupal\Core\Entity\Attribute\EntityReferenceSelection;
use Drupal\Core\Entity\Plugin\EntityReferenceSelection\DefaultSelection;
use Drupal\Core\Form\FormStateInterface;
use Drupal\Core\StringTranslation\TranslatableMarkup;
use Symfony\Component\DependencyInjection\ContainerInterface;
/**
* Provides specific access control for the workspace entity type.
*/
#[EntityReferenceSelection(
id: "default:workspace",
label: new TranslatableMarkup("Workspace selection"),
entity_types: ["workspace"],
group: "default",
weight: 1
)]
class WorkspaceSelection extends DefaultSelection {
/**
* The workspace repository service.
*
* @var \Drupal\workspaces\WorkspaceRepositoryInterface
*/
protected $workspaceRepository;
/**
* {@inheritdoc}
*/
public static function create(ContainerInterface $container, array $configuration, $plugin_id, $plugin_definition) {
$instance = parent::create($container, $configuration, $plugin_id, $plugin_definition);
$instance->workspaceRepository = $container->get('workspaces.repository');
return $instance;
}
/**
* {@inheritdoc}
*/
public function defaultConfiguration() {
return [
'sort' => [
'field' => 'label',
'direction' => 'asc',
],
] + parent::defaultConfiguration();
}
/**
* {@inheritdoc}
*/
public function buildConfigurationForm(array $form, FormStateInterface $form_state) {
$form = parent::buildConfigurationForm($form, $form_state);
// Sorting is not possible for workspaces because we always sort them by
// depth and label.
$form['sort']['#access'] = FALSE;
return $form;
}
/**
* {@inheritdoc}
*/
public function getReferenceableEntities($match = NULL, $match_operator = 'CONTAINS', $limit = 0) {
// Get all the workspace entities and sort them in tree order.
$storage = $this->entityTypeManager->getStorage('workspace');
$workspace_tree = $this->workspaceRepository->loadTree();
$entities = array_replace($workspace_tree, $storage->loadMultiple());
// If we need to restrict the list of workspaces by searching only a part of
// their label ($match) or by a number of results ($limit), the workspace
// tree would be mangled because it wouldn't contain all the tree items.
if ($match || $limit) {
$options = parent::getReferenceableEntities($match, $match_operator, $limit);
}
else {
$options = [];
foreach ($entities as $entity) {
$options[$entity->bundle()][$entity->id()] = str_repeat('-', $workspace_tree[$entity->id()]['depth']) . Html::escape($this->entityRepository->getTranslationFromContext($entity)->label());
}
}
$restricted_access_entities = [];
foreach ($options as $bundle => $bundle_options) {
foreach (array_keys($bundle_options) as $id) {
// If a user can not view a workspace, we need to prevent them from
// referencing that workspace as well as its descendants.
if (in_array($id, $restricted_access_entities) || !$entities[$id]->access('view', $this->currentUser)) {
$restricted_access_entities += $workspace_tree[$id]['descendants'];
unset($options[$bundle][$id]);
}
}
}
return $options;
}
}

View File

@@ -0,0 +1,25 @@
<?php
namespace Drupal\workspaces\Plugin\Validation\Constraint;
use Drupal\Core\StringTranslation\TranslatableMarkup;
use Drupal\Core\Validation\Attribute\Constraint;
use Symfony\Component\Validator\Constraint as SymfonyConstraint;
/**
* Deleted workspace constraint.
*/
#[Constraint(
id: 'DeletedWorkspace',
label: new TranslatableMarkup('Deleted workspace', [], ['context' => 'Validation'])
)]
class DeletedWorkspaceConstraint extends SymfonyConstraint {
/**
* The default violation message.
*
* @var string
*/
public $message = 'A workspace with this ID has been deleted but data still exists for it.';
}

View File

@@ -0,0 +1,58 @@
<?php
namespace Drupal\workspaces\Plugin\Validation\Constraint;
use Drupal\Core\DependencyInjection\ContainerInjectionInterface;
use Drupal\Core\State\StateInterface;
use Symfony\Component\DependencyInjection\ContainerInterface;
use Symfony\Component\Validator\Constraint;
use Symfony\Component\Validator\ConstraintValidator;
/**
* Checks if data still exists for a deleted workspace ID.
*/
class DeletedWorkspaceConstraintValidator extends ConstraintValidator implements ContainerInjectionInterface {
/**
* The state service.
*
* @var \Drupal\Core\State\StateInterface
*/
protected $state;
/**
* Creates a new DeletedWorkspaceConstraintValidator instance.
*
* @param \Drupal\Core\State\StateInterface $state
* The state service.
*/
public function __construct(StateInterface $state) {
$this->state = $state;
}
/**
* {@inheritdoc}
*/
public static function create(ContainerInterface $container) {
return new static(
$container->get('state')
);
}
/**
* {@inheritdoc}
*/
public function validate($value, Constraint $constraint) {
/** @var \Drupal\Core\Field\FieldItemListInterface $value */
// This constraint applies only to newly created workspace entities.
if (!isset($value) || !$value->getEntity()->isNew()) {
return;
}
$deleted_workspace_ids = $this->state->get('workspace.deleted', []);
if (isset($deleted_workspace_ids[$value->getEntity()->id()])) {
$this->context->addViolation($constraint->message);
}
}
}

View File

@@ -0,0 +1,25 @@
<?php
namespace Drupal\workspaces\Plugin\Validation\Constraint;
use Drupal\Core\StringTranslation\TranslatableMarkup;
use Drupal\Core\Validation\Attribute\Constraint;
use Symfony\Component\Validator\Constraint as SymfonyConstraint;
/**
* The entity reference supported new entities constraint.
*/
#[Constraint(
id: 'EntityReferenceSupportedNewEntities',
label: new TranslatableMarkup('Entity Reference Supported New Entities', [], ['context' => 'Validation'])
)]
class EntityReferenceSupportedNewEntitiesConstraint extends SymfonyConstraint {
/**
* The default violation message.
*
* @var string
*/
public $message = '%collection_label can only be created in the default workspace.';
}

View File

@@ -0,0 +1,76 @@
<?php
namespace Drupal\workspaces\Plugin\Validation\Constraint;
use Drupal\Core\DependencyInjection\ContainerInjectionInterface;
use Drupal\Core\Entity\EntityTypeManagerInterface;
use Drupal\workspaces\WorkspaceInformationInterface;
use Drupal\workspaces\WorkspaceManagerInterface;
use Symfony\Component\DependencyInjection\ContainerInterface;
use Symfony\Component\Validator\Constraint;
use Symfony\Component\Validator\ConstraintValidator;
/**
* Checks if new entities created for entity reference fields are supported.
*/
class EntityReferenceSupportedNewEntitiesConstraintValidator extends ConstraintValidator implements ContainerInjectionInterface {
/**
* The workspace manager.
*
* @var \Drupal\workspaces\WorkspaceManagerInterface
*/
protected $workspaceManager;
/**
* The entity type manager.
*
* @var \Drupal\Core\Entity\EntityTypeManagerInterface
*/
protected $entityTypeManager;
/**
* The workspace information service.
*
* @var \Drupal\workspaces\WorkspaceInformationInterface
*/
protected $workspaceInfo;
/**
* Creates a new EntityReferenceSupportedNewEntitiesConstraintValidator instance.
*/
public function __construct(WorkspaceManagerInterface $workspaceManager, EntityTypeManagerInterface $entityTypeManager, WorkspaceInformationInterface $workspace_information) {
$this->workspaceManager = $workspaceManager;
$this->entityTypeManager = $entityTypeManager;
$this->workspaceInfo = $workspace_information;
}
/**
* {@inheritdoc}
*/
public static function create(ContainerInterface $container) {
return new static(
$container->get('workspaces.manager'),
$container->get('entity_type.manager'),
$container->get('workspaces.information')
);
}
/**
* {@inheritdoc}
*/
public function validate($value, Constraint $constraint) {
// The validator should run only if we are in a active workspace context.
if (!$this->workspaceManager->hasActiveWorkspace()) {
return;
}
$target_entity_type_id = $value->getFieldDefinition()->getFieldStorageDefinition()->getSetting('target_type');
$target_entity_type = $this->entityTypeManager->getDefinition($target_entity_type_id);
if ($value->hasNewEntity() && !$this->workspaceInfo->isEntityTypeSupported($target_entity_type)) {
$this->context->addViolation($constraint->message, ['%collection_label' => $target_entity_type->getCollectionLabel()]);
}
}
}

View File

@@ -0,0 +1,26 @@
<?php
namespace Drupal\workspaces\Plugin\Validation\Constraint;
use Drupal\Core\StringTranslation\TranslatableMarkup;
use Drupal\Core\Validation\Attribute\Constraint;
use Symfony\Component\Validator\Constraint as SymfonyConstraint;
/**
* Validation constraint for an entity being edited in multiple workspaces.
*/
#[Constraint(
id: 'EntityWorkspaceConflict',
label: new TranslatableMarkup('Entity workspace conflict', [], ['context' => 'Validation']),
type: ['entity']
)]
class EntityWorkspaceConflictConstraint extends SymfonyConstraint {
/**
* The default violation message.
*
* @var string
*/
public $message = 'The content is being edited in the @label workspace. As a result, your changes cannot be saved.';
}

View File

@@ -0,0 +1,61 @@
<?php
namespace Drupal\workspaces\Plugin\Validation\Constraint;
use Drupal\Core\DependencyInjection\ContainerInjectionInterface;
use Drupal\Core\Entity\EntityTypeManagerInterface;
use Drupal\workspaces\WorkspaceAssociationInterface;
use Drupal\workspaces\WorkspaceManagerInterface;
use Symfony\Component\DependencyInjection\ContainerInterface;
use Symfony\Component\Validator\Constraint;
use Symfony\Component\Validator\ConstraintValidator;
/**
* Validates the EntityWorkspaceConflict constraint.
*
* @internal
*/
class EntityWorkspaceConflictConstraintValidator extends ConstraintValidator implements ContainerInjectionInterface {
public function __construct(
protected readonly EntityTypeManagerInterface $entityTypeManager,
protected readonly WorkspaceManagerInterface $workspaceManager,
protected readonly WorkspaceAssociationInterface $workspaceAssociation,
) {}
/**
* {@inheritdoc}
*/
public static function create(ContainerInterface $container) {
return new static(
$container->get('entity_type.manager'),
$container->get('workspaces.manager'),
$container->get('workspaces.association'),
);
}
/**
* {@inheritdoc}
*/
public function validate($entity, Constraint $constraint) {
/** @var \Drupal\Core\Entity\EntityInterface $entity */
if (isset($entity) && !$entity->isNew()) {
$active_workspace = $this->workspaceManager->getActiveWorkspace();
// If the entity is tracked in a workspace, it can only be edited in
// that workspace or one of its descendants.
if ($tracking_workspace_ids = $this->workspaceAssociation->getEntityTrackingWorkspaceIds($entity, TRUE)) {
if (!$active_workspace || !in_array($active_workspace->id(), $tracking_workspace_ids, TRUE)) {
$first_tracking_workspace_id = reset($tracking_workspace_ids);
$workspace = $this->entityTypeManager->getStorage('workspace')
->load($first_tracking_workspace_id);
$this->context->buildViolation($constraint->message)
->setParameter('@label', $workspace->label())
->addViolation();
}
}
}
}
}

View File

@@ -0,0 +1,462 @@
<?php
namespace Drupal\workspaces;
use Drupal\Core\DependencyInjection\ContainerInjectionInterface;
use Drupal\Core\Entity\EntityFieldManagerInterface;
use Drupal\Core\Entity\EntityTypeInterface;
use Drupal\Core\Entity\EntityTypeManagerInterface;
use Drupal\Core\Language\LanguageManagerInterface;
use Drupal\views\Plugin\views\query\QueryPluginBase;
use Drupal\views\Plugin\views\query\Sql;
use Drupal\views\Plugin\ViewsHandlerManager;
use Drupal\views\ViewExecutable;
use Drupal\views\ViewsData;
use Symfony\Component\DependencyInjection\ContainerInterface;
/**
* Defines a class for altering views queries.
*
* @internal
*/
class ViewsQueryAlter implements ContainerInjectionInterface {
/**
* The entity type manager service.
*
* @var \Drupal\Core\Entity\EntityTypeManagerInterface
*/
protected $entityTypeManager;
/**
* The entity field manager.
*
* @var \Drupal\Core\Entity\EntityFieldManagerInterface
*/
protected $entityFieldManager;
/**
* The workspace manager service.
*
* @var \Drupal\workspaces\WorkspaceManagerInterface
*/
protected $workspaceManager;
/**
* The views data.
*
* @var \Drupal\views\ViewsData
*/
protected $viewsData;
/**
* A plugin manager which handles instances of views join plugins.
*
* @var \Drupal\views\Plugin\ViewsHandlerManager
*/
protected $viewsJoinPluginManager;
/**
* The language manager.
*
* @var \Drupal\Core\Language\LanguageManagerInterface
*/
protected $languageManager;
/**
* The workspace information service.
*
* @var \Drupal\workspaces\WorkspaceInformationInterface
*/
protected WorkspaceInformationInterface $workspaceInfo;
/**
* An array of tables adjusted for workspace_association join.
*
* @var \WeakMap
*/
protected \WeakMap $adjustedTables;
/**
* Constructs a new ViewsQueryAlter instance.
*
* @param \Drupal\Core\Entity\EntityTypeManagerInterface $entity_type_manager
* The entity type manager service.
* @param \Drupal\Core\Entity\EntityFieldManagerInterface $entity_field_manager
* The entity field manager.
* @param \Drupal\workspaces\WorkspaceManagerInterface $workspace_manager
* The workspace manager service.
* @param \Drupal\views\ViewsData $views_data
* The views data.
* @param \Drupal\views\Plugin\ViewsHandlerManager $views_join_plugin_manager
* The views join plugin manager.
* @param \Drupal\Core\Language\LanguageManagerInterface $language_manager
* The language manager.
* @param \Drupal\workspaces\WorkspaceInformationInterface $workspace_information
* The workspace information service.
*/
public function __construct(EntityTypeManagerInterface $entity_type_manager, EntityFieldManagerInterface $entity_field_manager, WorkspaceManagerInterface $workspace_manager, ViewsData $views_data, ViewsHandlerManager $views_join_plugin_manager, LanguageManagerInterface $language_manager, WorkspaceInformationInterface $workspace_information) {
$this->entityTypeManager = $entity_type_manager;
$this->entityFieldManager = $entity_field_manager;
$this->workspaceManager = $workspace_manager;
$this->viewsData = $views_data;
$this->viewsJoinPluginManager = $views_join_plugin_manager;
$this->languageManager = $language_manager;
$this->workspaceInfo = $workspace_information;
$this->adjustedTables = new \WeakMap();
}
/**
* {@inheritdoc}
*/
public static function create(ContainerInterface $container) {
return new static(
$container->get('entity_type.manager'),
$container->get('entity_field.manager'),
$container->get('workspaces.manager'),
$container->get('views.views_data'),
$container->get('plugin.manager.views.join'),
$container->get('language_manager'),
$container->get('workspaces.information')
);
}
/**
* Implements a hook bridge for hook_views_query_alter().
*
* @see hook_views_query_alter()
*/
public function alterQuery(ViewExecutable $view, QueryPluginBase $query) {
// Don't alter any views queries if we're not in a workspace context.
if (!$this->workspaceManager->hasActiveWorkspace()) {
return;
}
// Don't alter any non-sql views queries.
if (!$query instanceof Sql) {
return;
}
// Find out what entity types are represented in this query.
$entity_type_ids = [];
foreach ($query->relationships as $info) {
$table_data = $this->viewsData->get($info['base']);
if (empty($table_data['table']['entity type'])) {
continue;
}
$entity_type_id = $table_data['table']['entity type'];
// This construct ensures each entity type exists only once.
$entity_type_ids[$entity_type_id] = $entity_type_id;
}
$entity_type_definitions = $this->entityTypeManager->getDefinitions();
foreach ($entity_type_ids as $entity_type_id) {
if ($this->workspaceInfo->isEntityTypeSupported($entity_type_definitions[$entity_type_id])) {
$this->alterQueryForEntityType($query, $entity_type_definitions[$entity_type_id]);
}
}
}
/**
* Alters the entity type tables for a Views query.
*
* This should only be called after determining that this entity type is
* involved in the query, and that a non-default workspace is in use.
*
* @param \Drupal\views\Plugin\views\query\Sql $query
* The query plugin object for the query.
* @param \Drupal\Core\Entity\EntityTypeInterface $entity_type
* The entity type definition.
*/
protected function alterQueryForEntityType(Sql $query, EntityTypeInterface $entity_type) {
/** @var \Drupal\Core\Entity\Sql\DefaultTableMapping $table_mapping */
$table_mapping = $this->entityTypeManager->getStorage($entity_type->id())->getTableMapping();
$field_storage_definitions = $this->entityFieldManager->getFieldStorageDefinitions($entity_type->id());
$dedicated_field_storage_definitions = array_filter($field_storage_definitions, function ($definition) use ($table_mapping) {
return $table_mapping->requiresDedicatedTableStorage($definition);
});
$dedicated_field_data_tables = array_map(function ($definition) use ($table_mapping) {
return $table_mapping->getDedicatedDataTableName($definition);
}, $dedicated_field_storage_definitions);
$move_workspace_tables = [];
$table_queue =& $query->getTableQueue();
foreach ($table_queue as $alias => &$table_info) {
// If we reach the workspace_association array item before any candidates,
// then we do not need to move it.
if ($table_info['table'] == 'workspace_association') {
break;
}
// Any dedicated field table is a candidate.
if ($field_name = array_search($table_info['table'], $dedicated_field_data_tables, TRUE)) {
$relationship = $table_info['relationship'];
// There can be reverse relationships used. If so, Workspaces can't do
// anything with them. Detect this and skip.
if ($table_info['join']->field != 'entity_id') {
continue;
}
// Get the dedicated revision table name.
$new_table_name = $table_mapping->getDedicatedRevisionTableName($field_storage_definitions[$field_name]);
// Now add the workspace_association table.
$workspace_association_table = $this->ensureWorkspaceAssociationTable($entity_type->id(), $query, $relationship);
// Update the join to use our COALESCE.
$revision_field = $entity_type->getKey('revision');
$table_info['join']->leftFormula = "COALESCE($workspace_association_table.target_entity_revision_id, $relationship.$revision_field)";
// Update the join and the table info to our new table name, and to join
// on the revision key.
$table_info['table'] = $new_table_name;
$table_info['join']->table = $new_table_name;
$table_info['join']->field = 'revision_id';
// Finally, if we added the workspace_association table we have to move
// it in the table queue so that it comes before this field.
if (empty($move_workspace_tables[$workspace_association_table])) {
$move_workspace_tables[$workspace_association_table] = $alias;
}
}
}
// JOINs must be in order. i.e, any tables you mention in the ON clause of a
// JOIN must appear prior to that JOIN. Since we're modifying a JOIN in
// place, and adding a new table, we must ensure that the new table appears
// prior to this one. So we recorded at what index we saw that table, and
// then use array_splice() to move the workspace_association table join to
// the correct position.
foreach ($move_workspace_tables as $workspace_association_table => $alias) {
$this->moveEntityTable($query, $workspace_association_table, $alias);
}
$base_entity_table = $entity_type->isTranslatable() ? $entity_type->getDataTable() : $entity_type->getBaseTable();
$base_fields = array_diff($table_mapping->getFieldNames($entity_type->getBaseTable()), [$entity_type->getKey('langcode')]);
$revisionable_fields = array_diff($table_mapping->getFieldNames($entity_type->getRevisionDataTable()), $base_fields);
// Go through and look to see if we have to modify fields and filters.
foreach ($query->fields as &$field_info) {
// Some fields don't actually have tables, meaning they're formulae and
// whatnot. At this time we are going to ignore those.
if (empty($field_info['table'])) {
continue;
}
// Dereference the alias into the actual table.
$table = $table_queue[$field_info['table']]['table'];
if ($table == $base_entity_table && in_array($field_info['field'], $revisionable_fields)) {
$relationship = $table_queue[$field_info['table']]['alias'];
$alias = $this->ensureRevisionTable($entity_type, $query, $relationship);
if ($alias) {
// Change the base table to use the revision table instead.
$field_info['table'] = $alias;
}
}
}
$relationships = [];
// Build a list of all relationships that might be for our table.
foreach ($query->relationships as $relationship => $info) {
if ($info['base'] == $base_entity_table) {
$relationships[] = $relationship;
}
}
// Now we have to go through our where clauses and modify any of our fields.
foreach ($query->where as &$clauses) {
foreach ($clauses['conditions'] as &$where_info) {
// Build a matrix of our possible relationships against fields we need
// to switch.
foreach ($relationships as $relationship) {
foreach ($revisionable_fields as $field) {
if (is_string($where_info['field']) && $where_info['field'] == "$relationship.$field") {
$alias = $this->ensureRevisionTable($entity_type, $query, $relationship);
if ($alias) {
// Change the base table to use the revision table instead.
$where_info['field'] = "$alias.$field";
}
}
}
}
}
}
// @todo Handle $query->orderby, $query->groupby, $query->having and
// $query->count_field in https://www.drupal.org/node/2968165.
}
/**
* Adds the 'workspace_association' table to a views query.
*
* @param string $entity_type_id
* The ID of the entity type to join.
* @param \Drupal\views\Plugin\views\query\Sql $query
* The query plugin object for the query.
* @param string $relationship
* The primary table alias this table is related to.
*
* @return string
* The alias of the 'workspace_association' table.
*/
protected function ensureWorkspaceAssociationTable($entity_type_id, Sql $query, $relationship) {
if (isset($query->tables[$relationship]['workspace_association'])) {
return $query->tables[$relationship]['workspace_association']['alias'];
}
$table_data = $this->viewsData->get($query->relationships[$relationship]['base']);
// Construct the join.
$definition = [
'table' => 'workspace_association',
'field' => 'target_entity_id',
'left_table' => $relationship,
'left_field' => $table_data['table']['base']['field'],
'extra' => [
[
'field' => 'target_entity_type_id',
'value' => $entity_type_id,
],
[
'field' => 'workspace',
'value' => $this->workspaceManager->getActiveWorkspace()->id(),
],
],
'type' => 'LEFT',
];
$join = $this->viewsJoinPluginManager->createInstance('standard', $definition);
$join->adjusted = TRUE;
return $query->queueTable('workspace_association', $relationship, $join);
}
/**
* Adds the revision table of an entity type to a query object.
*
* @param \Drupal\Core\Entity\EntityTypeInterface $entity_type
* The entity type definition.
* @param \Drupal\views\Plugin\views\query\Sql $query
* The query plugin object for the query.
* @param string $relationship
* The name of the relationship.
*
* @return string
* The alias of the relationship.
*/
protected function ensureRevisionTable(EntityTypeInterface $entity_type, Sql $query, $relationship) {
// Get the alias for the 'workspace_association' table we chain off of in
// the COALESCE.
$workspace_association_table = $this->ensureWorkspaceAssociationTable($entity_type->id(), $query, $relationship);
// Get the name of the revision table and revision key.
$base_revision_table = $entity_type->isTranslatable() ? $entity_type->getRevisionDataTable() : $entity_type->getRevisionTable();
$revision_field = $entity_type->getKey('revision');
// If the table was already added and has a join against the same field on
// the revision table, reuse that rather than adding a new join.
if (isset($query->tables[$relationship][$base_revision_table])) {
$table_queue =& $query->getTableQueue();
$alias = $query->tables[$relationship][$base_revision_table]['alias'];
if (isset($table_queue[$alias]['join']->field) && $table_queue[$alias]['join']->field == $revision_field) {
// If this table previously existed, but was not added by us, we need
// to modify the join and make sure that 'workspace_association' comes
// first.
if (!$this->adjustedTables->offsetExists($table_queue[$alias]['join'])) {
$table_queue[$alias]['join'] = $this->getRevisionTableJoin($relationship, $base_revision_table, $revision_field, $workspace_association_table, $entity_type);
// We also have to ensure that our 'workspace_association' comes before
// this.
$this->moveEntityTable($query, $workspace_association_table, $alias);
}
return $alias;
}
}
// Construct a new join.
$join = $this->getRevisionTableJoin($relationship, $base_revision_table, $revision_field, $workspace_association_table, $entity_type);
return $query->queueTable($base_revision_table, $relationship, $join);
}
/**
* Fetches a join for a revision table using the workspace_association table.
*
* @param string $relationship
* The relationship to use in the view.
* @param string $table
* The table name.
* @param string $field
* The field to join on.
* @param string $workspace_association_table
* The alias of the 'workspace_association' table joined to the main entity
* table.
* @param \Drupal\Core\Entity\EntityTypeInterface $entity_type
* The entity type that is being queried.
*
* @return \Drupal\views\Plugin\views\join\JoinPluginInterface
* An adjusted views join object to add to the query.
*
* @throws \Drupal\Component\Plugin\Exception\PluginException
*/
protected function getRevisionTableJoin($relationship, $table, $field, $workspace_association_table, EntityTypeInterface $entity_type) {
$definition = [
'table' => $table,
'field' => $field,
'left_table' => $relationship,
'left_formula' => "COALESCE($workspace_association_table.target_entity_revision_id, $relationship.$field)",
];
if ($entity_type->isTranslatable() && $this->languageManager->isMultilingual()) {
$langcode_field = $entity_type->getKey('langcode');
$definition['extra'] = [
['field' => $langcode_field, 'left_field' => $langcode_field],
];
}
/** @var \Drupal\views\Plugin\views\join\JoinPluginInterface $join */
$join = $this->viewsJoinPluginManager->createInstance('standard', $definition);
$join->adjusted = TRUE;
$this->adjustedTables[$join] = TRUE;
return $join;
}
/**
* Moves a 'workspace_association' table to appear before the given alias.
*
* Because Workspace chains possibly pre-existing tables onto the
* 'workspace_association' table, we have to ensure that the
* 'workspace_association' table appears in the query before the alias it's
* chained on or the SQL is invalid.
*
* @param \Drupal\views\Plugin\views\query\Sql $query
* The SQL query object.
* @param string $workspace_association_table
* The alias of the 'workspace_association' table.
* @param string $alias
* The alias of the table it needs to appear before.
*/
protected function moveEntityTable(Sql $query, $workspace_association_table, $alias) {
$table_queue =& $query->getTableQueue();
$keys = array_keys($table_queue);
$current_index = array_search($workspace_association_table, $keys);
$index = array_search($alias, $keys);
// If it's already before our table, we don't need to move it, as we could
// accidentally move it forward.
if ($current_index < $index) {
return;
}
$splice = [$workspace_association_table => $table_queue[$workspace_association_table]];
unset($table_queue[$workspace_association_table]);
// Now move the item to the proper location in the array. Don't use
// array_splice() because that breaks indices.
$table_queue = array_slice($table_queue, 0, $index, TRUE) +
$splice +
array_slice($table_queue, $index, NULL, TRUE);
}
}

View File

@@ -0,0 +1,69 @@
<?php
namespace Drupal\workspaces;
use Drupal\Core\Access\AccessResult;
use Drupal\Core\Entity\EntityAccessControlHandler;
use Drupal\Core\Entity\EntityInterface;
use Drupal\Core\Session\AccountInterface;
/**
* Defines the access control handler for the workspace entity type.
*
* @see \Drupal\workspaces\Entity\Workspace
*/
class WorkspaceAccessControlHandler extends EntityAccessControlHandler {
/**
* {@inheritdoc}
*/
protected function checkAccess(EntityInterface $entity, $operation, AccountInterface $account) {
/** @var \Drupal\workspaces\WorkspaceInterface $entity */
if ($operation === 'publish' && $entity->hasParent()) {
$message = $this->t('Only top-level workspaces can be published.');
return AccessResult::forbidden((string) $message)->addCacheableDependency($entity);
}
if ($account->hasPermission('administer workspaces')) {
return AccessResult::allowed()->cachePerPermissions();
}
// @todo Consider adding explicit "publish any|own workspace" permissions in
// https://www.drupal.org/project/drupal/issues/3084260.
switch ($operation) {
case 'update':
case 'publish':
$permission_operation = 'edit';
break;
case 'view all revisions':
$permission_operation = 'view';
break;
default:
$permission_operation = $operation;
break;
}
// Check if the user has permission to access all workspaces.
$access_result = AccessResult::allowedIfHasPermission($account, $permission_operation . ' any workspace');
// Check if it's their own workspace, and they have permission to access
// their own workspace.
if ($access_result->isNeutral() && $account->isAuthenticated() && $account->id() === $entity->getOwnerId()) {
$access_result = AccessResult::allowedIfHasPermission($account, $permission_operation . ' own workspace')
->cachePerUser()
->addCacheableDependency($entity);
}
return $access_result;
}
/**
* {@inheritdoc}
*/
protected function checkCreateAccess(AccountInterface $account, array $context, $entity_bundle = NULL) {
return AccessResult::allowedIfHasPermissions($account, ['administer workspaces', 'create workspace'], 'OR');
}
}

View File

@@ -0,0 +1,12 @@
<?php
namespace Drupal\workspaces;
use Drupal\Core\Access\AccessException;
/**
* Exception thrown when trying to switch to an inaccessible workspace.
*/
class WorkspaceAccessException extends AccessException {
}

View File

@@ -0,0 +1,445 @@
<?php
namespace Drupal\workspaces;
use Drupal\Core\Database\Connection;
use Drupal\Core\Database\Query\PagerSelectExtender;
use Drupal\Core\Entity\EntityTypeManagerInterface;
use Drupal\Core\Entity\RevisionableInterface;
use Drupal\Core\Entity\Sql\SqlContentEntityStorage;
use Drupal\Core\Utility\Error;
use Drupal\workspaces\Event\WorkspacePostPublishEvent;
use Drupal\workspaces\Event\WorkspacePublishEvent;
use Psr\Log\LoggerInterface;
use Symfony\Component\EventDispatcher\EventSubscriberInterface;
/**
* Provides a class for CRUD operations on workspace associations.
*/
class WorkspaceAssociation implements WorkspaceAssociationInterface, EventSubscriberInterface {
/**
* The table for the workspace association storage.
*/
const TABLE = 'workspace_association';
/**
* The database connection.
*
* @var \Drupal\Core\Database\Connection
*/
protected $database;
/**
* The entity type manager.
*
* @var \Drupal\Core\Entity\EntityTypeManagerInterface
*/
protected $entityTypeManager;
/**
* The workspace repository service.
*
* @var \Drupal\workspaces\WorkspaceRepositoryInterface
*/
protected $workspaceRepository;
/**
* A multidimensional array of entity IDs that are associated to a workspace.
*
* The first level keys are workspace IDs, the second level keys are entity
* type IDs, and the third level array are entity IDs, keyed by revision IDs.
*
* @var array
*/
protected array $associatedRevisions = [];
/**
* A multidimensional array of entity IDs that were created in a workspace.
*
* The first level keys are workspace IDs, the second level keys are entity
* type IDs, and the third level array are entity IDs, keyed by revision IDs.
*
* @var array
*/
protected array $associatedInitialRevisions = [];
/**
* Constructs a WorkspaceAssociation object.
*
* @param \Drupal\Core\Database\Connection $connection
* A database connection for reading and writing path aliases.
* @param \Drupal\Core\Entity\EntityTypeManagerInterface $entity_type_manager
* The entity type manager for querying revisions.
* @param \Drupal\workspaces\WorkspaceRepositoryInterface $workspace_repository
* The Workspace repository service.
* @param \Psr\Log\LoggerInterface|null $logger
* The logger.
*/
public function __construct(Connection $connection, EntityTypeManagerInterface $entity_type_manager, WorkspaceRepositoryInterface $workspace_repository, protected ?LoggerInterface $logger = NULL) {
$this->database = $connection;
$this->entityTypeManager = $entity_type_manager;
$this->workspaceRepository = $workspace_repository;
if ($this->logger === NULL) {
@trigger_error('Calling ' . __METHOD__ . '() without the $logger argument is deprecated in drupal:10.1.0 and it will be required in drupal:11.0.0. See https://www.drupal.org/node/2932520', E_USER_DEPRECATED);
$this->logger = \Drupal::service('logger.channel.workspaces');
}
}
/**
* {@inheritdoc}
*/
public function trackEntity(RevisionableInterface $entity, WorkspaceInterface $workspace) {
// Determine all workspaces that might be affected by this change.
$affected_workspaces = $this->workspaceRepository->getDescendantsAndSelf($workspace->id());
// Get the currently tracked revision for this workspace.
$tracked = $this->getTrackedEntities($workspace->id(), $entity->getEntityTypeId(), [$entity->id()]);
$tracked_revision_id = NULL;
if (isset($tracked[$entity->getEntityTypeId()])) {
$tracked_revision_id = key($tracked[$entity->getEntityTypeId()]);
}
try {
$transaction = $this->database->startTransaction();
// Update all affected workspaces that were tracking the current revision.
// This means they are inheriting content and should be updated.
if ($tracked_revision_id) {
$this->database->update(static::TABLE)
->fields([
'target_entity_revision_id' => $entity->getRevisionId(),
])
->condition('workspace', $affected_workspaces, 'IN')
->condition('target_entity_type_id', $entity->getEntityTypeId())
->condition('target_entity_id', $entity->id())
// Only update descendant workspaces if they have the same initial
// revision, which means they are currently inheriting content.
->condition('target_entity_revision_id', $tracked_revision_id)
->execute();
}
// Insert a new index entry for each workspace that is not tracking this
// entity yet.
$missing_workspaces = array_diff($affected_workspaces, $this->getEntityTrackingWorkspaceIds($entity));
if ($missing_workspaces) {
$insert_query = $this->database->insert(static::TABLE)
->fields([
'workspace',
'target_entity_revision_id',
'target_entity_type_id',
'target_entity_id',
]);
foreach ($missing_workspaces as $workspace_id) {
$insert_query->values([
'workspace' => $workspace_id,
'target_entity_type_id' => $entity->getEntityTypeId(),
'target_entity_id' => $entity->id(),
'target_entity_revision_id' => $entity->getRevisionId(),
]);
}
$insert_query->execute();
}
}
catch (\Exception $e) {
if (isset($transaction)) {
$transaction->rollBack();
}
Error::logException($this->logger, $e);
throw $e;
}
$this->associatedRevisions = $this->associatedInitialRevisions = [];
}
/**
* {@inheritdoc}
*/
public function workspaceInsert(WorkspaceInterface $workspace) {
// When a new workspace has been saved, we need to copy all the associations
// of its parent.
if ($workspace->hasParent()) {
$this->initializeWorkspace($workspace);
}
}
/**
* {@inheritdoc}
*/
public function getTrackedEntities($workspace_id, $entity_type_id = NULL, $entity_ids = NULL) {
$query = $this->database->select(static::TABLE);
$query
->fields(static::TABLE, ['target_entity_type_id', 'target_entity_id', 'target_entity_revision_id'])
->orderBy('target_entity_revision_id', 'ASC')
->condition('workspace', $workspace_id);
if ($entity_type_id) {
$query->condition('target_entity_type_id', $entity_type_id, '=');
if ($entity_ids) {
$query->condition('target_entity_id', $entity_ids, 'IN');
}
}
$tracked_revisions = [];
foreach ($query->execute() as $record) {
$tracked_revisions[$record->target_entity_type_id][$record->target_entity_revision_id] = $record->target_entity_id;
}
return $tracked_revisions;
}
/**
* {@inheritdoc}
*/
public function getTrackedEntitiesForListing($workspace_id, ?int $pager_id = NULL, int|false $limit = 50): array {
$query = $this->database->select(static::TABLE)
->extend(PagerSelectExtender::class)
->limit($limit);
if ($pager_id) {
$query->element($pager_id);
}
$query
->fields(static::TABLE, ['target_entity_type_id', 'target_entity_id', 'target_entity_revision_id'])
->orderBy('target_entity_type_id', 'ASC')
->orderBy('target_entity_revision_id', 'DESC')
->condition('workspace', $workspace_id);
$tracked_revisions = [];
foreach ($query->execute() as $record) {
$tracked_revisions[$record->target_entity_type_id][$record->target_entity_revision_id] = $record->target_entity_id;
}
return $tracked_revisions;
}
/**
* {@inheritdoc}
*/
public function getAssociatedRevisions($workspace_id, $entity_type_id, $entity_ids = NULL) {
if (isset($this->associatedRevisions[$workspace_id][$entity_type_id])) {
if ($entity_ids) {
return array_intersect($this->associatedRevisions[$workspace_id][$entity_type_id], $entity_ids);
}
else {
return $this->associatedRevisions[$workspace_id][$entity_type_id];
}
}
/** @var \Drupal\Core\Entity\EntityStorageInterface $storage */
$storage = $this->entityTypeManager->getStorage($entity_type_id);
// If the entity type is not using core's default entity storage, we can't
// assume the table mapping layout so we have to return only the latest
// tracked revisions.
if (!$storage instanceof SqlContentEntityStorage) {
return $this->getTrackedEntities($workspace_id, $entity_type_id, $entity_ids)[$entity_type_id];
}
$entity_type = $storage->getEntityType();
$table_mapping = $storage->getTableMapping();
$workspace_field = $table_mapping->getColumnNames($entity_type->get('revision_metadata_keys')['workspace'])['target_id'];
$id_field = $table_mapping->getColumnNames($entity_type->getKey('id'))['value'];
$revision_id_field = $table_mapping->getColumnNames($entity_type->getKey('revision'))['value'];
$workspace_tree = $this->workspaceRepository->loadTree();
if (isset($workspace_tree[$workspace_id])) {
$workspace_candidates = array_merge([$workspace_id], $workspace_tree[$workspace_id]['ancestors']);
}
else {
$workspace_candidates = [$workspace_id];
}
$query = $this->database->select($entity_type->getRevisionTable(), 'revision');
$query->leftJoin($entity_type->getBaseTable(), 'base', "[revision].[$id_field] = [base].[$id_field]");
$query
->fields('revision', [$revision_id_field, $id_field])
->condition("revision.$workspace_field", $workspace_candidates, 'IN')
->where("[revision].[$revision_id_field] >= [base].[$revision_id_field]")
->orderBy("revision.$revision_id_field", 'ASC');
// Restrict the result to a set of entity ID's if provided.
if ($entity_ids) {
$query->condition("revision.$id_field", $entity_ids, 'IN');
}
$result = $query->execute()->fetchAllKeyed();
// Cache the list of associated entity IDs if the full list was requested.
if (!$entity_ids) {
$this->associatedRevisions[$workspace_id][$entity_type_id] = $result;
}
return $result;
}
/**
* {@inheritdoc}
*/
public function getAssociatedInitialRevisions(string $workspace_id, string $entity_type_id, array $entity_ids = []) {
if (isset($this->associatedInitialRevisions[$workspace_id][$entity_type_id])) {
if ($entity_ids) {
return array_intersect($this->associatedInitialRevisions[$workspace_id][$entity_type_id], $entity_ids);
}
else {
return $this->associatedInitialRevisions[$workspace_id][$entity_type_id];
}
}
/** @var \Drupal\Core\Entity\EntityStorageInterface $storage */
$storage = $this->entityTypeManager->getStorage($entity_type_id);
// If the entity type is not using core's default entity storage, we can't
// assume the table mapping layout so we have to return only the latest
// tracked revisions.
if (!$storage instanceof SqlContentEntityStorage) {
return $this->getTrackedEntities($workspace_id, $entity_type_id, $entity_ids)[$entity_type_id];
}
$entity_type = $storage->getEntityType();
$table_mapping = $storage->getTableMapping();
$workspace_field = $table_mapping->getColumnNames($entity_type->get('revision_metadata_keys')['workspace'])['target_id'];
$id_field = $table_mapping->getColumnNames($entity_type->getKey('id'))['value'];
$revision_id_field = $table_mapping->getColumnNames($entity_type->getKey('revision'))['value'];
$query = $this->database->select($entity_type->getBaseTable(), 'base');
$query->leftJoin($entity_type->getRevisionTable(), 'revision', "[base].[$revision_id_field] = [revision].[$revision_id_field]");
$query
->fields('base', [$revision_id_field, $id_field])
->condition("revision.$workspace_field", $workspace_id, '=')
->orderBy("base.$revision_id_field", 'ASC');
// Restrict the result to a set of entity ID's if provided.
if ($entity_ids) {
$query->condition("base.$id_field", $entity_ids, 'IN');
}
$result = $query->execute()->fetchAllKeyed();
// Cache the list of associated entity IDs if the full list was requested.
if (!$entity_ids) {
$this->associatedInitialRevisions[$workspace_id][$entity_type_id] = $result;
}
return $result;
}
/**
* {@inheritdoc}
*/
public function getEntityTrackingWorkspaceIds(RevisionableInterface $entity, bool $latest_revision = FALSE) {
$query = $this->database->select(static::TABLE, 'wa')
->fields('wa', ['workspace'])
->condition('[wa].[target_entity_type_id]', $entity->getEntityTypeId())
->condition('[wa].[target_entity_id]', $entity->id());
// Use a self-join to get only the workspaces in which the latest revision
// of the entity is tracked.
if ($latest_revision) {
$inner_select = $this->database->select(static::TABLE, 'wai')
->condition('[wai].[target_entity_type_id]', $entity->getEntityTypeId())
->condition('[wai].[target_entity_id]', $entity->id());
$inner_select->addExpression('MAX([wai].[target_entity_revision_id])', 'max_revision_id');
$query->join($inner_select, 'waj', '[wa].[target_entity_revision_id] = [waj].[max_revision_id]');
}
$result = $query->execute()->fetchCol();
// Return early if the entity is not tracked in any workspace.
if (empty($result)) {
return [];
}
// Return workspace IDs sorted in tree order.
$tree = $this->workspaceRepository->loadTree();
return array_keys(array_intersect_key($tree, array_flip($result)));
}
/**
* {@inheritdoc}
*/
public function postPublish(WorkspaceInterface $workspace) {
@trigger_error(__METHOD__ . '() is deprecated in drupal:10.1.0 and is removed from drupal:11.0.0. Use the \Drupal\workspaces\Event\WorkspacePostPublishEvent event instead. See https://www.drupal.org/node/3242573', E_USER_DEPRECATED);
$this->deleteAssociations($workspace->id());
}
/**
* {@inheritdoc}
*/
public function deleteAssociations($workspace_id = NULL, $entity_type_id = NULL, $entity_ids = NULL, $revision_ids = NULL) {
if (!$workspace_id && !$entity_type_id) {
throw new \InvalidArgumentException('A workspace ID or an entity type ID must be provided.');
}
$query = $this->database->delete(static::TABLE);
if ($workspace_id) {
$query->condition('workspace', $workspace_id);
}
if ($entity_type_id) {
if (!$entity_ids && !$revision_ids) {
throw new \InvalidArgumentException('A list of entity IDs or revision IDs must be provided for an entity type.');
}
$query->condition('target_entity_type_id', $entity_type_id, '=');
if ($entity_ids) {
$query->condition('target_entity_id', $entity_ids, 'IN');
}
if ($revision_ids) {
$query->condition('target_entity_revision_id', $revision_ids, 'IN');
}
}
$query->execute();
$this->associatedRevisions = $this->associatedInitialRevisions = [];
}
/**
* {@inheritdoc}
*/
public function initializeWorkspace(WorkspaceInterface $workspace) {
if ($parent_id = $workspace->parent->target_id) {
$indexed_rows = $this->database->select(static::TABLE);
$indexed_rows->addExpression(':new_id', 'workspace', [
':new_id' => $workspace->id(),
]);
$indexed_rows->fields(static::TABLE, [
'target_entity_type_id',
'target_entity_id',
'target_entity_revision_id',
]);
$indexed_rows->condition('workspace', $parent_id);
$this->database->insert(static::TABLE)->from($indexed_rows)->execute();
}
$this->associatedRevisions = $this->associatedInitialRevisions = [];
}
/**
* {@inheritdoc}
*/
public static function getSubscribedEvents(): array {
// Workspace association records cleanup should happen as late as possible.
$events[WorkspacePostPublishEvent::class][] = ['onPostPublish', -500];
return $events;
}
/**
* Triggers clean-up operations after a workspace is published.
*
* @param \Drupal\workspaces\Event\WorkspacePublishEvent $event
* The workspace publish event.
*/
public function onPostPublish(WorkspacePublishEvent $event): void {
$this->deleteAssociations($event->getWorkspace()->id());
}
}

View File

@@ -0,0 +1,168 @@
<?php
namespace Drupal\workspaces;
use Drupal\Core\Entity\RevisionableInterface;
/**
* Defines an interface for the workspace_association service.
*
* The canonical workspace association data is stored in a revision metadata
* field on each entity revision that is tracked by a workspace.
*
* For the purpose of optimizing workspace-specific queries, the default
* implementation of this interface defines a custom 'workspace_association'
* index table which stores only the latest revisions tracked by a workspace.
*
* @internal
*/
interface WorkspaceAssociationInterface {
/**
* Updates or creates the association for a given entity and a workspace.
*
* @param \Drupal\Core\Entity\RevisionableInterface $entity
* The entity to update or create from.
* @param \Drupal\workspaces\WorkspaceInterface $workspace
* The workspace in which the entity will be tracked.
*/
public function trackEntity(RevisionableInterface $entity, WorkspaceInterface $workspace);
/**
* Responds to the creation of a new workspace entity.
*
* @param \Drupal\workspaces\WorkspaceInterface $workspace
* The workspaces that was inserted.
*/
public function workspaceInsert(WorkspaceInterface $workspace);
/**
* Retrieves the entities tracked by a given workspace.
*
* @param string $workspace_id
* The ID of the workspace.
* @param string|null $entity_type_id
* (optional) An entity type ID to filter the results by. Defaults to NULL.
* @param int[]|string[]|null $entity_ids
* (optional) An array of entity IDs to filter the results by. Defaults to
* NULL.
*
* @return array
* Returns a multidimensional array where the first level keys are entity
* type IDs and the values are an array of entity IDs keyed by revision IDs.
*/
public function getTrackedEntities($workspace_id, $entity_type_id = NULL, $entity_ids = NULL);
/**
* Retrieves a paged list of entities tracked by a given workspace.
*
* @param string $workspace_id
* The ID of the workspace.
* @param int|null $pager_id
* (optional) A pager ID. Defaults to NULL.
* @param int|false $limit
* (optional) An integer specifying the number of elements per page. If
* passed a false value (FALSE, 0, NULL), the pager is disabled. Defaults to
* 50.
*
* @return array
* Returns a multidimensional array where the first level keys are entity
* type IDs and the values are an array of entity IDs keyed by revision IDs.
*/
public function getTrackedEntitiesForListing($workspace_id, ?int $pager_id = NULL, int|false $limit = 50): array;
/**
* Retrieves all content revisions tracked by a given workspace.
*
* Since the 'workspace_association' index table only tracks the latest
* associated revisions, this method retrieves all the tracked revisions by
* querying the entity type's revision table directly.
*
* @param string $workspace_id
* The ID of the workspace.
* @param string $entity_type_id
* An entity type ID to find revisions for.
* @param int[]|string[]|null $entity_ids
* (optional) An array of entity IDs to filter the results by. Defaults to
* NULL.
*
* @return array
* Returns an array where the values are an array of entity IDs keyed by
* revision IDs.
*/
public function getAssociatedRevisions($workspace_id, $entity_type_id, $entity_ids = NULL);
/**
* Retrieves all content revisions that were created in a given workspace.
*
* @param string $workspace_id
* The ID of the workspace.
* @param string $entity_type_id
* An entity type ID to find revisions for.
* @param int[]|string[] $entity_ids
* (optional) An array of entity IDs to filter the results by. Defaults to
* an empty array.
*
* @return array
* Returns an array where the values are an array of entity IDs keyed by
* revision IDs.
*/
public function getAssociatedInitialRevisions(string $workspace_id, string $entity_type_id, array $entity_ids = []);
/**
* Gets a list of workspace IDs in which an entity is tracked.
*
* @param \Drupal\Core\Entity\RevisionableInterface $entity
* An entity object.
* @param bool $latest_revision
* (optional) Whether to return only the workspaces in which the latest
* revision of the entity is tracked. Defaults to FALSE.
*
* @return string[]
* An array of workspace IDs where the given entity is tracked, or an empty
* array if it is not tracked anywhere.
*/
public function getEntityTrackingWorkspaceIds(RevisionableInterface $entity, bool $latest_revision = FALSE);
/**
* Triggers clean-up operations after publishing a workspace.
*
* @param \Drupal\workspaces\WorkspaceInterface $workspace
* A workspace entity.
*
* @deprecated in drupal:10.1.0 and is removed from drupal:11.0.0. Use the
* \Drupal\workspaces\Event\WorkspacePostPublishEvent event instead.
*
* @see https://www.drupal.org/node/3242573
*/
public function postPublish(WorkspaceInterface $workspace);
/**
* Deletes all the workspace association records for the given workspace.
*
* @param string|null $workspace_id
* (optional) A workspace entity ID. Defaults to NULL.
* @param string|null $entity_type_id
* (optional) The target entity type of the associations to delete. Defaults
* to NULL.
* @param int[]|string[]|null $entity_ids
* (optional) The target entity IDs of the associations to delete. Defaults
* to NULL.
* @param int[]|string[]|null $revision_ids
* (optional) The target entity revision IDs of the associations to delete.
* Defaults to NULL.
*
* @throws \InvalidArgumentException
* If neither $workspace_id nor $entity_type_id arguments were provided.
*/
public function deleteAssociations($workspace_id = NULL, $entity_type_id = NULL, $entity_ids = NULL, $revision_ids = NULL);
/**
* Initializes a workspace with all the associations of its parent.
*
* @param \Drupal\workspaces\WorkspaceInterface $workspace
* The workspace to be initialized.
*/
public function initializeWorkspace(WorkspaceInterface $workspace);
}

View File

@@ -0,0 +1,57 @@
<?php
namespace Drupal\workspaces;
use Drupal\Core\Cache\CacheableMetadata;
use Drupal\Core\Cache\Context\CacheContextInterface;
/**
* Defines the WorkspaceCacheContext service, for "per workspace" caching.
*
* Cache context ID: 'workspace'.
*/
class WorkspaceCacheContext implements CacheContextInterface {
/**
* The workspace manager.
*
* @var \Drupal\workspaces\WorkspaceManagerInterface
*/
protected $workspaceManager;
/**
* Constructs a new WorkspaceCacheContext service.
*
* @param \Drupal\workspaces\WorkspaceManagerInterface $workspace_manager
* The workspace manager.
*/
public function __construct(WorkspaceManagerInterface $workspace_manager) {
$this->workspaceManager = $workspace_manager;
}
/**
* {@inheritdoc}
*/
public static function getLabel() {
return t('Workspace');
}
/**
* {@inheritdoc}
*/
public function getContext() {
return $this->workspaceManager->hasActiveWorkspace() ? $this->workspaceManager->getActiveWorkspace()->id() : 'live';
}
/**
* {@inheritdoc}
*/
public function getCacheableMetadata($type = NULL) {
// The active workspace will always be stored in the user's session.
$cacheability = new CacheableMetadata();
$cacheability->addCacheContexts(['session']);
return $cacheability;
}
}

View File

@@ -0,0 +1,10 @@
<?php
namespace Drupal\workspaces;
/**
* An exception thrown when two workspaces are in a conflicting content state.
*/
class WorkspaceConflictException extends \RuntimeException {
}

View File

@@ -0,0 +1,121 @@
<?php
namespace Drupal\workspaces;
use Drupal\Core\Entity\EntityInterface;
use Drupal\Core\Entity\EntityPublishedInterface;
use Drupal\Core\Entity\EntityTypeInterface;
use Drupal\Core\Entity\EntityTypeManagerInterface;
use Drupal\workspaces\Entity\Handler\IgnoredWorkspaceHandler;
/**
* General service for workspace support information.
*/
class WorkspaceInformation implements WorkspaceInformationInterface {
/**
* An array of workspace-support statuses, keyed by entity type ID.
*
* @var bool[]
*/
protected array $supported = [];
/**
* An array of workspace-ignored statuses, keyed by entity type ID.
*
* @var bool[]
*/
protected array $ignored = [];
public function __construct(
protected readonly EntityTypeManagerInterface $entityTypeManager,
protected readonly WorkspaceAssociationInterface $workspaceAssociation,
) {}
/**
* {@inheritdoc}
*/
public function isEntitySupported(EntityInterface $entity): bool {
$entity_type = $entity->getEntityType();
if (!$this->isEntityTypeSupported($entity_type)) {
return FALSE;
}
$handler = $this->entityTypeManager->getHandler($entity_type->id(), 'workspace');
return $handler->isEntitySupported($entity);
}
/**
* {@inheritdoc}
*/
public function isEntityTypeSupported(EntityTypeInterface $entity_type): bool {
if (!isset($this->supported[$entity_type->id()])) {
if ($entity_type->hasHandlerClass('workspace')) {
$supported = !is_a($entity_type->getHandlerClass('workspace'), IgnoredWorkspaceHandler::class, TRUE);
}
else {
// Fallback for cases when entity type info hasn't been altered yet, for
// example when the Workspaces module is being installed.
$supported = $entity_type->entityClassImplements(EntityPublishedInterface::class) && $entity_type->isRevisionable();
}
$this->supported[$entity_type->id()] = $supported;
}
return $this->supported[$entity_type->id()];
}
/**
* {@inheritdoc}
*/
public function getSupportedEntityTypes(): array {
$entity_types = [];
foreach ($this->entityTypeManager->getDefinitions() as $entity_type_id => $entity_type) {
if ($this->isEntityTypeSupported($entity_type)) {
$entity_types[$entity_type_id] = $entity_type;
}
}
return $entity_types;
}
/**
* {@inheritdoc}
*/
public function isEntityIgnored(EntityInterface $entity): bool {
$entity_type = $entity->getEntityType();
if ($this->isEntityTypeIgnored($entity_type)) {
return TRUE;
}
if ($entity_type->hasHandlerClass('workspace')) {
$handler = $this->entityTypeManager->getHandler($entity_type->id(), 'workspace');
return !$handler->isEntitySupported($entity);
}
return FALSE;
}
/**
* {@inheritdoc}
*/
public function isEntityTypeIgnored(EntityTypeInterface $entity_type): bool {
if (!isset($this->ignored[$entity_type->id()])) {
$this->ignored[$entity_type->id()] = $entity_type->hasHandlerClass('workspace')
&& is_a($entity_type->getHandlerClass('workspace'), IgnoredWorkspaceHandler::class, TRUE);
}
return $this->ignored[$entity_type->id()];
}
/**
* {@inheritdoc}
*/
public function isEntityDeletable(EntityInterface $entity, WorkspaceInterface $workspace): bool {
$initial_revisions = $this->workspaceAssociation->getAssociatedInitialRevisions($workspace->id(), $entity->getEntityTypeId());
return in_array($entity->id(), $initial_revisions, TRUE);
}
}

View File

@@ -0,0 +1,86 @@
<?php
namespace Drupal\workspaces;
use Drupal\Core\Entity\EntityInterface;
use Drupal\Core\Entity\EntityTypeInterface;
/**
* Provides an interface for workspace-support information.
*/
interface WorkspaceInformationInterface {
/**
* Determines whether an entity can belong to a workspace.
*
* @param \Drupal\Core\Entity\EntityInterface $entity
* The entity to check.
*
* @return bool
* TRUE if the entity can belong to a workspace, FALSE otherwise.
*/
public function isEntitySupported(EntityInterface $entity): bool;
/**
* Determines whether an entity type can belong to a workspace.
*
* @param \Drupal\Core\Entity\EntityTypeInterface $entity_type
* The entity type to check.
*
* @return bool
* TRUE if the entity type can belong to a workspace, FALSE otherwise.
*/
public function isEntityTypeSupported(EntityTypeInterface $entity_type): bool;
/**
* Returns an array of entity types that can belong to workspaces.
*
* @return \Drupal\Core\Entity\EntityTypeInterface[]
* An array of entity type definition objects.
*/
public function getSupportedEntityTypes(): array;
/**
* Determines whether CRUD operations for an entity are allowed.
*
* CRUD operations for an ignored entity are allowed in a workspace, but their
* revisions are not tracked.
*
* @param \Drupal\Core\Entity\EntityInterface $entity
* The entity to check.
*
* @return bool
* TRUE if CRUD operations of an entity type can safely be done inside a
* workspace, without impacting the Live site, FALSE otherwise.
*/
public function isEntityIgnored(EntityInterface $entity): bool;
/**
* Determines whether CRUD operations for an entity type are allowed.
*
* CRUD operations for an ignored entity type are allowed in a workspace, but
* their revisions are not tracked.
*
* @param \Drupal\Core\Entity\EntityTypeInterface $entity_type
* The entity type to check.
*
* @return bool
* TRUE if CRUD operations of an entity type can safely be done inside a
* workspace, without impacting the Live site, FALSE otherwise.
*/
public function isEntityTypeIgnored(EntityTypeInterface $entity_type): bool;
/**
* Determines whether an entity can be deleted in the given workspace.
*
* @param \Drupal\Core\Entity\EntityInterface $entity
* The entity object which needs to be checked.
* @param \Drupal\workspaces\WorkspaceInterface $workspace
* The workspace in which the entity needs to be checked.
*
* @return bool
* TRUE if the entity can be deleted, FALSE otherwise.
*/
public function isEntityDeletable(EntityInterface $entity, WorkspaceInterface $workspace): bool;
}

View File

@@ -0,0 +1,45 @@
<?php
namespace Drupal\workspaces;
use Drupal\Core\Entity\ContentEntityInterface;
use Drupal\Core\Entity\EntityChangedInterface;
use Drupal\user\EntityOwnerInterface;
/**
* Defines an interface for the workspace entity type.
*/
interface WorkspaceInterface extends ContentEntityInterface, EntityChangedInterface, EntityOwnerInterface {
/**
* Publishes the contents of this workspace to the default (Live) workspace.
*/
public function publish();
/**
* Gets the workspace creation timestamp.
*
* @return int
* Creation timestamp of the workspace.
*/
public function getCreatedTime();
/**
* Sets the workspace creation timestamp.
*
* @param int $timestamp
* The workspace creation timestamp.
*
* @return $this
*/
public function setCreatedTime($timestamp);
/**
* Determines whether the workspace has a parent.
*
* @return bool
* TRUE if the workspace has a parent, FALSE otherwise.
*/
public function hasParent();
}

View File

@@ -0,0 +1,402 @@
<?php
namespace Drupal\workspaces;
use Drupal\Component\Serialization\Json;
use Drupal\Core\Ajax\AjaxHelperTrait;
use Drupal\Core\Entity\EntityInterface;
use Drupal\Core\Entity\EntityListBuilder;
use Drupal\Core\Entity\EntityStorageInterface;
use Drupal\Core\Entity\EntityTypeInterface;
use Drupal\Core\Render\RendererInterface;
use Drupal\Core\Url;
use Drupal\user\UserInterface;
use Symfony\Component\DependencyInjection\ContainerInterface;
/**
* Defines a class to build a listing of workspace entities.
*
* @see \Drupal\workspaces\Entity\Workspace
*/
class WorkspaceListBuilder extends EntityListBuilder {
use AjaxHelperTrait;
/**
* The workspace manager service.
*
* @var \Drupal\workspaces\WorkspaceManagerInterface
*/
protected $workspaceManager;
/**
* The workspace repository service.
*
* @var \Drupal\workspaces\WorkspaceRepositoryInterface
*/
protected $workspaceRepository;
/**
* The renderer service.
*
* @var \Drupal\Core\Render\RendererInterface
*/
protected $renderer;
/**
* Constructs a new EntityListBuilder object.
*
* @param \Drupal\Core\Entity\EntityTypeInterface $entity_type
* The entity type definition.
* @param \Drupal\Core\Entity\EntityStorageInterface $storage
* The entity storage class.
* @param \Drupal\workspaces\WorkspaceManagerInterface $workspace_manager
* The workspace manager service.
* @param \Drupal\workspaces\WorkspaceRepositoryInterface $workspace_repository
* The workspace repository service.
* @param \Drupal\Core\Render\RendererInterface $renderer
* The renderer service.
*/
public function __construct(EntityTypeInterface $entity_type, EntityStorageInterface $storage, WorkspaceManagerInterface $workspace_manager, WorkspaceRepositoryInterface $workspace_repository, RendererInterface $renderer) {
parent::__construct($entity_type, $storage);
$this->workspaceManager = $workspace_manager;
$this->workspaceRepository = $workspace_repository;
$this->renderer = $renderer;
}
/**
* {@inheritdoc}
*/
public static function createInstance(ContainerInterface $container, EntityTypeInterface $entity_type) {
return new static(
$entity_type,
$container->get('entity_type.manager')->getStorage($entity_type->id()),
$container->get('workspaces.manager'),
$container->get('workspaces.repository'),
$container->get('renderer')
);
}
/**
* {@inheritdoc}
*/
public function load() {
// Get all the workspace entities and sort them in tree order.
$workspace_tree = $this->workspaceRepository->loadTree();
$entities = array_replace($workspace_tree, $this->storage->loadMultiple());
foreach ($entities as $id => $entity) {
$entity->_depth = $workspace_tree[$id]['depth'];
}
return $entities;
}
/**
* {@inheritdoc}
*/
public function buildHeader() {
$header['label'] = $this->t('Workspace');
$header['uid'] = $this->t('Owner');
return $header + parent::buildHeader();
}
/**
* {@inheritdoc}
*/
public function buildRow(EntityInterface $entity) {
/** @var \Drupal\workspaces\WorkspaceInterface $entity */
if (isset($entity->_depth) && $entity->_depth > 0) {
$indentation = [
'#theme' => 'indentation',
'#size' => $entity->_depth,
];
}
$row['data'] = [
'label' => [
'data' => [
'#prefix' => isset($indentation) ? $this->renderer->render($indentation) : '',
'#type' => 'link',
'#title' => $entity->label(),
'#url' => $entity->toUrl(),
],
],
'owner' => (($owner = $entity->getOwner()) && $owner instanceof UserInterface)
? $owner->getDisplayName()
: $this->t('N/A'),
];
$row['data'] = $row['data'] + parent::buildRow($entity);
$active_workspace = $this->workspaceManager->getActiveWorkspace();
if ($active_workspace && $entity->id() === $active_workspace->id()) {
$row['class'] = ['active-workspace', 'active-workspace--not-default'];
}
return $row;
}
/**
* {@inheritdoc}
*/
public function getDefaultOperations(EntityInterface $entity) {
/** @var \Drupal\workspaces\WorkspaceInterface $entity */
$operations = parent::getDefaultOperations($entity);
if (isset($operations['edit'])) {
$operations['edit']['query']['destination'] = $entity->toUrl('collection')->toString();
}
$active_workspace = $this->workspaceManager->getActiveWorkspace();
if (!$active_workspace || $entity->id() != $active_workspace->id()) {
$operations['activate'] = [
'title' => $this->t('Switch to @workspace', ['@workspace' => $entity->label()]),
// Use a weight lower than the one of the 'Edit' operation because we
// want the 'Activate' operation to be the primary operation.
'weight' => 0,
'url' => $entity->toUrl('activate-form', ['query' => ['destination' => $entity->toUrl('collection')->toString()]]),
];
}
if (!$entity->hasParent()) {
$operations['publish'] = [
'title' => $this->t('Publish content'),
// The 'Publish' operation should be the default one for the currently
// active workspace.
'weight' => ($active_workspace && $entity->id() == $active_workspace->id()) ? 0 : 20,
'url' => Url::fromRoute('entity.workspace.publish_form',
['workspace' => $entity->id()],
['query' => ['destination' => $entity->toUrl('collection')->toString()]]
),
];
}
else {
/** @var \Drupal\workspaces\WorkspaceInterface $parent */
$parent = $entity->parent->entity;
$operations['merge'] = [
'title' => $this->t('Merge into @target_label', [
'@target_label' => $parent->label(),
]),
'weight' => 5,
'url' => Url::fromRoute('entity.workspace.merge_form',
[
'source_workspace' => $entity->id(),
'target_workspace' => $parent->id(),
],
[
'query' => ['destination' => $entity->toUrl('collection')->toString()],
]
),
];
}
$operations['manage'] = [
'title' => $this->t('Manage'),
'weight' => 5,
'url' => $entity->toUrl(),
];
return $operations;
}
/**
* {@inheritdoc}
*/
public function render() {
$build = parent::render();
if ($this->isAjax()) {
$this->offCanvasRender($build);
}
else {
// Add a row for switching to Live.
$has_active_workspace = $this->workspaceManager->hasActiveWorkspace();
$row_live = [
'data' => [
'label' => [
'data' => [
'#markup' => $this->t('Live'),
],
],
'owner' => '',
'operations' => [
'data' => [
'#type' => 'operations',
'#links' => [
'activate' => [
'title' => 'Switch to Live',
'weight' => 0,
'url' => Url::fromRoute('workspaces.switch_to_live', [], ['query' => $this->getDestinationArray()]),
],
],
'#access' => $has_active_workspace,
],
],
],
];
if (!$has_active_workspace) {
$row_live['class'] = ['active-workspace', 'active-workspace--default'];
}
array_unshift($build['table']['#rows'], $row_live);
$build['#attached'] = [
'library' => ['workspaces/drupal.workspaces.overview'],
];
}
return $build;
}
/**
* Renders the off canvas elements.
*
* @param array $build
* A render array.
*/
protected function offCanvasRender(array &$build) {
$active_workspace = $this->workspaceManager->getActiveWorkspace();
if ($active_workspace) {
$active_workspace_classes = [
'active-workspace--not-default',
'active-workspace--' . $active_workspace->id(),
];
}
else {
$active_workspace_classes = [
'active-workspace--default',
];
}
$build['active_workspace'] = [
'#type' => 'container',
'#weight' => -20,
'#attributes' => [
'class' => array_merge(['active-workspace'], $active_workspace_classes),
],
'title' => [
'#type' => 'html_tag',
'#tag' => 'div',
'#value' => $this->t('Current workspace:'),
'#attributes' => ['class' => 'active-workspace__title'],
],
'label' => [
'#type' => 'container',
'#attributes' => ['class' => 'active-workspace__label'],
'value' => [
'#type' => 'html_tag',
'#tag' => 'span',
'#value' => $active_workspace ? $active_workspace->label() : $this->t('Live'),
],
],
];
if ($active_workspace) {
$build['active_workspace']['label']['manage'] = [
'#type' => 'link',
'#title' => $this->t('Manage workspace'),
'#url' => $active_workspace->toUrl('canonical'),
'#attributes' => [
'class' => ['active-workspace__manage'],
],
];
$build['active_workspace']['actions'] = [
'#type' => 'container',
'#weight' => 20,
'#attributes' => [
'class' => ['active-workspace__actions'],
],
];
if (!$active_workspace->hasParent()) {
$build['active_workspace']['actions']['publish'] = [
'#type' => 'link',
'#title' => $this->t('Publish content'),
'#url' => Url::fromRoute('entity.workspace.publish_form',
['workspace' => $active_workspace->id()],
['query' => ['destination' => $active_workspace->toUrl('collection')->toString()]]
),
'#attributes' => [
'class' => ['button', 'button--primary', 'active-workspace__button'],
],
];
}
else {
$build['active_workspace']['actions']['merge'] = [
'#type' => 'link',
'#title' => $this->t('Merge content'),
'#url' => Url::fromRoute('entity.workspace.merge_form',
[
'source_workspace' => $active_workspace->id(),
'target_workspace' => $active_workspace->parent->target_id,
],
[
'query' => ['destination' => $active_workspace->toUrl('collection')->toString()],
]
),
'#attributes' => [
'class' => ['button', 'button--primary', 'active-workspace__button'],
],
];
}
}
$items = [];
$rows = array_slice($build['table']['#rows'], 0, 5, TRUE);
foreach ($rows as $id => $row) {
if (!$active_workspace || $active_workspace->id() !== $id) {
$url = Url::fromRoute('entity.workspace.activate_form', ['workspace' => $id], ['query' => $this->getDestinationArray()]);
$items[] = [
'#type' => 'link',
'#title' => ltrim($row['data']['label']['data']['#title']),
'#url' => $url,
'#attributes' => [
'class' => ['use-ajax', 'workspaces__item', 'workspaces__item--not-default'],
'data-dialog-type' => 'modal',
'data-dialog-options' => Json::encode([
'width' => 500,
]),
],
];
}
}
// Add an item for switching to Live.
if ($active_workspace) {
$items[] = [
'#type' => 'link',
'#title' => $this->t('Live'),
'#url' => Url::fromRoute('workspaces.switch_to_live', [], ['query' => $this->getDestinationArray()]),
'#attributes' => [
'class' => ['use-ajax', 'workspaces__item', 'workspaces__item--default'],
'data-dialog-type' => 'modal',
'data-dialog-options' => Json::encode([
'width' => 500,
]),
],
];
}
$build['workspaces_list'] = [
'#type' => 'container',
'#attributes' => [
'class' => 'workspaces',
],
];
$build['workspaces_list']['workspaces'] = [
'#theme' => 'item_list',
'#title' => $this->t('Other workspaces:'),
'#items' => $items,
'#wrapper_attributes' => ['class' => ['workspaces__list']],
'#cache' => [
'contexts' => $this->entityType->getListCacheContexts(),
'tags' => $this->entityType->getListCacheTags(),
],
];
$build['workspaces_list']['all_workspaces'] = [
'#type' => 'link',
'#title' => $this->t('View all workspaces'),
'#url' => Url::fromRoute('entity.workspace.collection'),
'#attributes' => [
'class' => ['all-workspaces'],
],
];
unset($build['table']);
unset($build['pager']);
}
}

View File

@@ -0,0 +1,393 @@
<?php
namespace Drupal\workspaces;
use Drupal\Core\Cache\MemoryCache\MemoryCacheInterface;
use Drupal\Core\DependencyInjection\ClassResolverInterface;
use Drupal\Core\Entity\EntityTypeInterface;
use Drupal\Core\Entity\EntityTypeManagerInterface;
use Drupal\Core\Session\AccountProxyInterface;
use Drupal\Core\Site\Settings;
use Drupal\Core\State\StateInterface;
use Drupal\Core\StringTranslation\StringTranslationTrait;
use Psr\Log\LoggerInterface;
use Symfony\Component\HttpFoundation\RequestStack;
/**
* Provides the workspace manager.
*/
class WorkspaceManager implements WorkspaceManagerInterface {
use StringTranslationTrait;
/**
* The request stack.
*
* @var \Symfony\Component\HttpFoundation\RequestStack
*/
protected $requestStack;
/**
* The entity type manager.
*
* @var \Drupal\Core\Entity\EntityTypeManagerInterface
*/
protected $entityTypeManager;
/**
* The entity memory cache service.
*
* @var \Drupal\Core\Cache\MemoryCache\MemoryCacheInterface
*/
protected $entityMemoryCache;
/**
* The current user.
*
* @var \Drupal\Core\Session\AccountProxyInterface
*/
protected $currentUser;
/**
* The state service.
*
* @var \Drupal\Core\State\StateInterface
*/
protected $state;
/**
* A logger instance.
*
* @var \Psr\Log\LoggerInterface
*/
protected $logger;
/**
* The class resolver.
*
* @var \Drupal\Core\DependencyInjection\ClassResolverInterface
*/
protected $classResolver;
/**
* The workspace association service.
*
* @var \Drupal\workspaces\WorkspaceAssociationInterface
*/
protected $workspaceAssociation;
/**
* The workspace information service.
*
* @var \Drupal\workspaces\WorkspaceInformationInterface
*/
protected WorkspaceInformationInterface $workspaceInfo;
/**
* The workspace negotiator service IDs.
*
* @var array
*/
protected $negotiatorIds;
/**
* The current active workspace or FALSE if there is no active workspace.
*
* @var \Drupal\workspaces\WorkspaceInterface|false
*/
protected $activeWorkspace;
/**
* Constructs a new WorkspaceManager.
*
* @param \Symfony\Component\HttpFoundation\RequestStack $request_stack
* The request stack.
* @param \Drupal\Core\Entity\EntityTypeManagerInterface $entity_type_manager
* The entity type manager.
* @param \Drupal\Core\Cache\MemoryCache\MemoryCacheInterface $entity_memory_cache
* The entity memory cache service.
* @param \Drupal\Core\Session\AccountProxyInterface $current_user
* The current user.
* @param \Drupal\Core\State\StateInterface $state
* The state service.
* @param \Psr\Log\LoggerInterface $logger
* A logger instance.
* @param \Drupal\Core\DependencyInjection\ClassResolverInterface $class_resolver
* The class resolver.
* @param \Drupal\workspaces\WorkspaceAssociationInterface $workspace_association
* The workspace association service.
* @param \Drupal\workspaces\WorkspaceInformationInterface|null $workspace_information
* The workspace information service.
* @param array|null $negotiator_ids
* The workspace negotiator service IDs.
*/
public function __construct(RequestStack $request_stack, EntityTypeManagerInterface $entity_type_manager, MemoryCacheInterface $entity_memory_cache, AccountProxyInterface $current_user, StateInterface $state, LoggerInterface $logger, ClassResolverInterface $class_resolver, WorkspaceAssociationInterface $workspace_association, protected ?WorkspaceInformationInterface $workspace_information = NULL, ?array $negotiator_ids = NULL) {
$this->requestStack = $request_stack;
$this->entityTypeManager = $entity_type_manager;
$this->entityMemoryCache = $entity_memory_cache;
$this->currentUser = $current_user;
$this->state = $state;
$this->logger = $logger;
$this->classResolver = $class_resolver;
$this->workspaceAssociation = $workspace_association;
if (!$workspace_information instanceof WorkspaceInformationInterface) {
@trigger_error('Calling ' . __METHOD__ . '() without the $workspace_information argument is deprecated in drupal:10.3.0 and will be required in drupal:11.0.0. See https://www.drupal.org/node/3324297', E_USER_DEPRECATED);
$this->workspaceInfo = \Drupal::service('workspaces.information');
// The negotiator IDs are always the last constructor argument.
$this->negotiatorIds = $workspace_information;
}
else {
$this->workspaceInfo = $workspace_information;
$this->negotiatorIds = $negotiator_ids;
}
}
/**
* {@inheritdoc}
*/
public function isEntityTypeSupported(EntityTypeInterface $entity_type) {
@trigger_error(__METHOD__ . '() is deprecated in drupal:10.3.0 and is removed from drupal:11.0.0. Use \Drupal\workspaces\WorkspaceInformation::isEntityTypeSupported instead. See https://www.drupal.org/node/3324297', E_USER_DEPRECATED);
return $this->workspaceInfo->isEntityTypeSupported($entity_type);
}
/**
* {@inheritdoc}
*/
public function getSupportedEntityTypes() {
@trigger_error(__METHOD__ . '() is deprecated in drupal:10.3.0 and is removed from drupal:11.0.0. Use \Drupal\workspaces\WorkspaceInformation::getSupportedEntityTypes instead. See https://www.drupal.org/node/3324297', E_USER_DEPRECATED);
return $this->workspaceInfo->getSupportedEntityTypes();
}
/**
* {@inheritdoc}
*/
public function hasActiveWorkspace() {
return $this->getActiveWorkspace() !== FALSE;
}
/**
* {@inheritdoc}
*/
public function getActiveWorkspace() {
if (!isset($this->activeWorkspace)) {
$request = $this->requestStack->getCurrentRequest();
foreach ($this->negotiatorIds as $negotiator_id) {
/** @var \Drupal\workspaces\Negotiator\WorkspaceIdNegotiatorInterface $negotiator */
$negotiator = $this->classResolver->getInstanceFromDefinition($negotiator_id);
if ($negotiator->applies($request)) {
if ($workspace_id = $negotiator->getActiveWorkspaceId($request)) {
/** @var \Drupal\workspaces\WorkspaceInterface $negotiated_workspace */
$negotiated_workspace = $this->entityTypeManager
->getStorage('workspace')
->load($workspace_id);
}
// By default, 'view' access is checked when a workspace is activated,
// but it should also be checked when retrieving the currently active
// workspace.
if (isset($negotiated_workspace) && $negotiated_workspace->access('view')) {
// Notify the negotiator that its workspace has been selected.
$negotiator->setActiveWorkspace($negotiated_workspace);
$active_workspace = $negotiated_workspace;
break;
}
}
}
// If no negotiator was able to provide a valid workspace, default to the
// live version of the site.
$this->activeWorkspace = $active_workspace ?? FALSE;
}
return $this->activeWorkspace;
}
/**
* {@inheritdoc}
*/
public function setActiveWorkspace(WorkspaceInterface $workspace) {
$this->doSwitchWorkspace($workspace);
// Set the workspace on the proper negotiator.
$request = $this->requestStack->getCurrentRequest();
foreach ($this->negotiatorIds as $negotiator_id) {
$negotiator = $this->classResolver->getInstanceFromDefinition($negotiator_id);
if ($negotiator->applies($request)) {
$negotiator->setActiveWorkspace($workspace);
break;
}
}
return $this;
}
/**
* {@inheritdoc}
*/
public function switchToLive() {
$this->doSwitchWorkspace(NULL);
// Unset the active workspace on all negotiators.
foreach ($this->negotiatorIds as $negotiator_id) {
$negotiator = $this->classResolver->getInstanceFromDefinition($negotiator_id);
$negotiator->unsetActiveWorkspace();
}
return $this;
}
/**
* Switches the current workspace.
*
* @param \Drupal\workspaces\WorkspaceInterface|null $workspace
* The workspace to set as active or NULL to switch out of the currently
* active workspace.
*
* @throws \Drupal\workspaces\WorkspaceAccessException
* Thrown when the current user doesn't have access to view the workspace.
*/
protected function doSwitchWorkspace($workspace) {
// If the current user doesn't have access to view the workspace, they
// shouldn't be allowed to switch to it, except in CLI processes.
if ($workspace && PHP_SAPI !== 'cli' && !$workspace->access('view')) {
$this->logger->error('Denied access to view workspace %workspace_label for user %uid', [
'%workspace_label' => $workspace->label(),
'%uid' => $this->currentUser->id(),
]);
throw new WorkspaceAccessException('The user does not have permission to view that workspace.');
}
$this->activeWorkspace = $workspace ?: FALSE;
// Clear the static entity cache for the supported entity types.
$cache_tags_to_invalidate = array_map(function ($entity_type_id) {
return 'entity.memory_cache:' . $entity_type_id;
}, array_keys($this->workspaceInfo->getSupportedEntityTypes()));
$this->entityMemoryCache->invalidateTags($cache_tags_to_invalidate);
// Clear the static cache for path aliases. We can't inject the path alias
// manager service because it would create a circular dependency.
if (\Drupal::hasService('path_alias.manager')) {
\Drupal::service('path_alias.manager')->cacheClear();
}
}
/**
* {@inheritdoc}
*/
public function executeInWorkspace($workspace_id, callable $function) {
/** @var \Drupal\workspaces\WorkspaceInterface $workspace */
$workspace = $this->entityTypeManager->getStorage('workspace')->load($workspace_id);
if (!$workspace) {
throw new \InvalidArgumentException('The ' . $workspace_id . ' workspace does not exist.');
}
$previous_active_workspace = $this->getActiveWorkspace();
$this->doSwitchWorkspace($workspace);
$result = $function();
$this->doSwitchWorkspace($previous_active_workspace);
return $result;
}
/**
* {@inheritdoc}
*/
public function executeOutsideWorkspace(callable $function) {
$previous_active_workspace = $this->getActiveWorkspace();
$this->doSwitchWorkspace(NULL);
$result = $function();
$this->doSwitchWorkspace($previous_active_workspace);
return $result;
}
/**
* {@inheritdoc}
*/
public function shouldAlterOperations(EntityTypeInterface $entity_type) {
@trigger_error(__METHOD__ . '() is deprecated in drupal:10.3.0 and is removed from drupal:11.0.0. There is no replacement. See https://www.drupal.org/node/3324297', E_USER_DEPRECATED);
return $this->workspaceInfo->isEntityTypeSupported($entity_type) && $this->hasActiveWorkspace();
}
/**
* {@inheritdoc}
*/
public function purgeDeletedWorkspacesBatch() {
$deleted_workspace_ids = $this->state->get('workspace.deleted', []);
// Bail out early if there are no workspaces to purge.
if (empty($deleted_workspace_ids)) {
return;
}
$batch_size = Settings::get('entity_update_batch_size', 50);
// Get the first deleted workspace from the list and delete the revisions
// associated with it, along with the workspace association records.
$workspace_id = reset($deleted_workspace_ids);
$all_associated_revisions = [];
foreach (array_keys($this->workspaceInfo->getSupportedEntityTypes()) as $entity_type_id) {
$all_associated_revisions[$entity_type_id] = $this->workspaceAssociation->getAssociatedRevisions($workspace_id, $entity_type_id);
}
$all_associated_revisions = array_filter($all_associated_revisions);
$count = 1;
foreach ($all_associated_revisions as $entity_type_id => $associated_revisions) {
/** @var \Drupal\Core\Entity\RevisionableStorageInterface $associated_entity_storage */
$associated_entity_storage = $this->entityTypeManager->getStorage($entity_type_id);
// Sort the associated revisions in reverse ID order, so we can delete the
// most recent revisions first.
krsort($associated_revisions);
// Get a list of default revisions tracked by the given workspace, because
// they need to be handled differently than pending revisions.
$initial_revision_ids = $this->workspaceAssociation->getAssociatedInitialRevisions($workspace_id, $entity_type_id);
foreach (array_keys($associated_revisions) as $revision_id) {
if ($count > $batch_size) {
continue 2;
}
// If the workspace is tracking the entity's default revision (i.e. the
// entity was created inside that workspace), we need to delete the
// whole entity after all of its pending revisions are gone.
if (isset($initial_revision_ids[$revision_id])) {
$associated_entity_storage->delete([$associated_entity_storage->load($initial_revision_ids[$revision_id])]);
}
else {
// Delete the associated entity revision.
$associated_entity_storage->deleteRevision($revision_id);
}
$count++;
}
}
// The purging operation above might have taken a long time, so we need to
// request a fresh list of tracked entities. If it is empty, we can go ahead
// and remove the deleted workspace ID entry from state.
$has_associated_revisions = FALSE;
foreach (array_keys($this->workspaceInfo->getSupportedEntityTypes()) as $entity_type_id) {
if (!empty($this->workspaceAssociation->getAssociatedRevisions($workspace_id, $entity_type_id))) {
$has_associated_revisions = TRUE;
break;
}
}
if (!$has_associated_revisions) {
unset($deleted_workspace_ids[$workspace_id]);
$this->state->set('workspace.deleted', $deleted_workspace_ids);
// Delete any possible leftover association entries.
$this->workspaceAssociation->deleteAssociations($workspace_id);
}
}
}

View File

@@ -0,0 +1,123 @@
<?php
namespace Drupal\workspaces;
use Drupal\Core\Entity\EntityTypeInterface;
/**
* Provides an interface for managing Workspaces.
*/
interface WorkspaceManagerInterface {
/**
* Returns whether an entity type can belong to a workspace or not.
*
* @param \Drupal\Core\Entity\EntityTypeInterface $entity_type
* The entity type to check.
*
* @return bool
* TRUE if the entity type can belong to a workspace, FALSE otherwise.
*
* @deprecated in drupal:10.3.0 and is removed from drupal:11.0.0. Use
* \Drupal\workspaces\WorkspaceInformation::isEntityTypeSupported instead.
*
* @see https://www.drupal.org/node/3324297
*/
public function isEntityTypeSupported(EntityTypeInterface $entity_type);
/**
* Returns an array of entity types that can belong to workspaces.
*
* @return \Drupal\Core\Entity\EntityTypeInterface[]
* The entity types what can belong to workspaces.
*
* @deprecated in drupal:10.3.0 and is removed from drupal:11.0.0. Use
* \Drupal\workspaces\WorkspaceInformation::getSupportedEntityTypes instead.
*
* @see https://www.drupal.org/node/3324297
*/
public function getSupportedEntityTypes();
/**
* Determines whether a workspace is active in the current request.
*
* @return bool
* TRUE if a workspace is active, FALSE otherwise.
*/
public function hasActiveWorkspace();
/**
* Gets the active workspace.
*
* @return \Drupal\workspaces\WorkspaceInterface
* The active workspace entity object.
*/
public function getActiveWorkspace();
/**
* Sets the active workspace via the workspace negotiators.
*
* @param \Drupal\workspaces\WorkspaceInterface $workspace
* The workspace to set as active.
*
* @return $this
*
* @throws \Drupal\workspaces\WorkspaceAccessException
* Thrown when the current user doesn't have access to view the workspace.
*/
public function setActiveWorkspace(WorkspaceInterface $workspace);
/**
* Unsets the active workspace via the workspace negotiators.
*
* @return $this
*/
public function switchToLive();
/**
* Executes the given callback function in the context of a workspace.
*
* @param string $workspace_id
* The ID of a workspace.
* @param callable $function
* The callback to be executed.
*
* @return mixed
* The callable's return value.
*/
public function executeInWorkspace($workspace_id, callable $function);
/**
* Executes the given callback function without any workspace context.
*
* @param callable $function
* The callback to be executed.
*
* @return mixed
* The callable's return value.
*/
public function executeOutsideWorkspace(callable $function);
/**
* Determines whether runtime entity operations should be altered.
*
* @param \Drupal\Core\Entity\EntityTypeInterface $entity_type
* The entity type to check.
*
* @return bool
* TRUE if the entity operations or queries should be altered in the current
* request, FALSE otherwise.
*
* @deprecated in drupal:10.3.0 and is removed from drupal:11.0.0. There is no
* replacement.
*
* @see https://www.drupal.org/node/3324297
*/
public function shouldAlterOperations(EntityTypeInterface $entity_type);
/**
* Deletes the revisions associated with deleted workspaces.
*/
public function purgeDeletedWorkspacesBatch();
}

View File

@@ -0,0 +1,210 @@
<?php
namespace Drupal\workspaces;
use Drupal\Core\Database\Connection;
use Drupal\Core\Entity\EntityTypeManagerInterface;
use Drupal\Core\Utility\Error;
use Psr\Log\LoggerInterface;
/**
* Default implementation of the workspace merger.
*
* @internal
*/
class WorkspaceMerger implements WorkspaceMergerInterface {
/**
* The source workspace entity.
*
* @var \Drupal\workspaces\WorkspaceInterface
*/
protected $sourceWorkspace;
/**
* The target workspace entity.
*
* @var \Drupal\workspaces\WorkspaceInterface
*/
protected $targetWorkspace;
/**
* The entity type manager.
*
* @var \Drupal\Core\Entity\EntityTypeManagerInterface
*/
protected $entityTypeManager;
/**
* The database connection.
*
* @var \Drupal\Core\Database\Connection
*/
protected $database;
/**
* The workspace association service.
*
* @var \Drupal\workspaces\WorkspaceAssociationInterface
*/
protected $workspaceAssociation;
/**
* Constructs a new WorkspaceMerger.
*
* @param \Drupal\Core\Entity\EntityTypeManagerInterface $entity_type_manager
* The entity type manager.
* @param \Drupal\Core\Database\Connection $database
* Database connection.
* @param \Drupal\workspaces\WorkspaceAssociationInterface $workspace_association
* The workspace association service.
* @param \Drupal\workspaces\WorkspaceInterface $source
* The source workspace.
* @param \Drupal\workspaces\WorkspaceInterface $target
* The target workspace.
* @param \Psr\Log\LoggerInterface|null $logger
* The logger.
*/
public function __construct(EntityTypeManagerInterface $entity_type_manager, Connection $database, WorkspaceAssociationInterface $workspace_association, WorkspaceInterface $source, WorkspaceInterface $target, protected ?LoggerInterface $logger = NULL) {
$this->entityTypeManager = $entity_type_manager;
$this->database = $database;
$this->workspaceAssociation = $workspace_association;
$this->sourceWorkspace = $source;
$this->targetWorkspace = $target;
if ($this->logger === NULL) {
@trigger_error('Calling ' . __METHOD__ . '() without the $logger argument is deprecated in drupal:10.1.0 and it will be required in drupal:11.0.0. See https://www.drupal.org/node/2932520', E_USER_DEPRECATED);
$this->logger = \Drupal::service('logger.channel.workspaces');
}
}
/**
* {@inheritdoc}
*/
public function merge() {
if (!$this->sourceWorkspace->hasParent() || $this->sourceWorkspace->parent->target_id != $this->targetWorkspace->id()) {
throw new \InvalidArgumentException('The contents of a workspace can only be merged into its parent workspace.');
}
if ($this->checkConflictsOnTarget()) {
throw new WorkspaceConflictException();
}
try {
$transaction = $this->database->startTransaction();
foreach ($this->getDifferringRevisionIdsOnSource() as $entity_type_id => $revision_difference) {
$entity_type = $this->entityTypeManager->getDefinition($entity_type_id);
$revisions_on_source = $this->entityTypeManager->getStorage($entity_type_id)
->loadMultipleRevisions(array_keys($revision_difference));
/** @var \Drupal\Core\Entity\ContentEntityInterface $revision */
foreach ($revisions_on_source as $revision) {
// Track all the differing revisions from the source workspace in
// the context of the target workspace. This will automatically
// update all the descendants of the target workspace as well.
$this->workspaceAssociation->trackEntity($revision, $this->targetWorkspace);
// Set the workspace in which the revision was merged.
$field_name = $entity_type->getRevisionMetadataKey('workspace');
$revision->{$field_name}->target_id = $this->targetWorkspace->id();
$revision->setSyncing(TRUE);
$revision->save();
}
}
}
catch (\Exception $e) {
if (isset($transaction)) {
$transaction->rollBack();
}
Error::logException($this->logger, $e);
throw $e;
}
}
/**
* {@inheritdoc}
*/
public function getSourceLabel() {
return $this->sourceWorkspace->label();
}
/**
* {@inheritdoc}
*/
public function getTargetLabel() {
return $this->targetWorkspace->label();
}
/**
* {@inheritdoc}
*/
public function checkConflictsOnTarget() {
// Nothing to do for now, we can not get to a conflicting state because an
// entity which is being edited in a workspace can not be edited in any
// other workspace.
}
/**
* {@inheritdoc}
*/
public function getDifferringRevisionIdsOnTarget() {
$target_revision_difference = [];
$tracked_entities_on_source = $this->workspaceAssociation->getTrackedEntities($this->sourceWorkspace->id());
$tracked_entities_on_target = $this->workspaceAssociation->getTrackedEntities($this->targetWorkspace->id());
foreach ($tracked_entities_on_target as $entity_type_id => $tracked_revisions) {
// Now we compare the revision IDs which are tracked by the target
// workspace to those that are tracked by the source workspace, and the
// difference between these two arrays gives us all the entities which
// have a different revision ID on the target.
if (!isset($tracked_entities_on_source[$entity_type_id])) {
$target_revision_difference[$entity_type_id] = $tracked_revisions;
}
elseif ($revision_difference = array_diff_key($tracked_revisions, $tracked_entities_on_source[$entity_type_id])) {
$target_revision_difference[$entity_type_id] = $revision_difference;
}
}
return $target_revision_difference;
}
/**
* {@inheritdoc}
*/
public function getDifferringRevisionIdsOnSource() {
$source_revision_difference = [];
$tracked_entities_on_source = $this->workspaceAssociation->getTrackedEntities($this->sourceWorkspace->id());
$tracked_entities_on_target = $this->workspaceAssociation->getTrackedEntities($this->targetWorkspace->id());
foreach ($tracked_entities_on_source as $entity_type_id => $tracked_revisions) {
// Now we compare the revision IDs which are tracked by the source
// workspace to those that are tracked by the target workspace, and the
// difference between these two arrays gives us all the entities which
// have a different revision ID on the source.
if (!isset($tracked_entities_on_target[$entity_type_id])) {
$source_revision_difference[$entity_type_id] = $tracked_revisions;
}
elseif ($revision_difference = array_diff_key($tracked_revisions, $tracked_entities_on_target[$entity_type_id])) {
$source_revision_difference[$entity_type_id] = $revision_difference;
}
}
return $source_revision_difference;
}
/**
* {@inheritdoc}
*/
public function getNumberOfChangesOnTarget() {
$total_changes = $this->getDifferringRevisionIdsOnTarget();
return count($total_changes, COUNT_RECURSIVE) - count($total_changes);
}
/**
* {@inheritdoc}
*/
public function getNumberOfChangesOnSource() {
$total_changes = $this->getDifferringRevisionIdsOnSource();
return count($total_changes, COUNT_RECURSIVE) - count($total_changes);
}
}

View File

@@ -0,0 +1,17 @@
<?php
namespace Drupal\workspaces;
/**
* Defines an interface for the workspace merger.
*
* @internal
*/
interface WorkspaceMergerInterface extends WorkspaceOperationInterface {
/**
* Merges the contents of the source workspace into the target workspace.
*/
public function merge();
}

View File

@@ -0,0 +1,143 @@
<?php
namespace Drupal\workspaces;
use Drupal\Core\Cache\CacheTagsInvalidatorInterface;
use Drupal\Core\Database\Connection;
use Drupal\Core\DependencyInjection\DeprecatedServicePropertyTrait;
use Drupal\Core\Entity\EntityTypeManagerInterface;
use Psr\Log\LoggerInterface;
use Symfony\Contracts\EventDispatcher\EventDispatcherInterface;
/**
* Defines a factory class for workspace operations.
*
* @see \Drupal\workspaces\WorkspaceOperationInterface
* @see \Drupal\workspaces\WorkspacePublisherInterface
*
* @internal
*/
class WorkspaceOperationFactory {
use DeprecatedServicePropertyTrait;
/**
* Defines deprecated injected properties.
*
* @var array
*/
protected array $deprecatedProperties = [
'cacheTagInvalidator' => 'cache_tags.invalidator',
];
/**
* The entity type manager.
*
* @var \Drupal\Core\Entity\EntityTypeManagerInterface
*/
protected $entityTypeManager;
/**
* The database connection.
*
* @var \Drupal\Core\Database\Connection
*/
protected $database;
/**
* The workspace manager.
*
* @var \Drupal\workspaces\WorkspaceManagerInterface
*/
protected $workspaceManager;
/**
* The workspace association service.
*
* @var \Drupal\workspaces\WorkspaceAssociationInterface
*/
protected $workspaceAssociation;
/**
* An event dispatcher instance to use for configuration events.
*
* @var \Symfony\Contracts\EventDispatcher\EventDispatcherInterface
*/
protected $eventDispatcher;
/**
* The logger service.
*
* @var \Psr\Log\LoggerInterface
*/
protected $logger;
/**
* Constructs a new WorkspaceOperationFactory.
*
* @param \Drupal\Core\Entity\EntityTypeManagerInterface $entity_type_manager
* The entity type manager.
* @param \Drupal\Core\Database\Connection $database
* Database connection.
* @param \Drupal\workspaces\WorkspaceManagerInterface $workspace_manager
* The workspace manager service.
* @param \Drupal\workspaces\WorkspaceAssociationInterface $workspace_association
* The workspace association service.
* @param \Symfony\Contracts\EventDispatcher\EventDispatcherInterface|\Drupal\Core\Cache\CacheTagsInvalidatorInterface $event_dispatcher
* The event dispatcher.
* @param \Psr\Log\LoggerInterface|null $logger
* The logger.
*/
public function __construct(EntityTypeManagerInterface $entity_type_manager, Connection $database, WorkspaceManagerInterface $workspace_manager, WorkspaceAssociationInterface $workspace_association, EventDispatcherInterface|CacheTagsInvalidatorInterface $event_dispatcher, LoggerInterface|EventDispatcherInterface|null $logger = NULL) {
$this->entityTypeManager = $entity_type_manager;
$this->database = $database;
$this->workspaceManager = $workspace_manager;
$this->workspaceAssociation = $workspace_association;
if ($event_dispatcher instanceof CacheTagsInvalidatorInterface) {
$event_dispatcher = \Drupal::service('event_dispatcher');
@trigger_error('Calling ' . __METHOD__ . '() with the $cache_tags_invalidator argument is deprecated in drupal:10.3.0 and is removed from drupal:11.0.0. See https://www.drupal.org/node/3440755', E_USER_DEPRECATED);
}
elseif (!$event_dispatcher instanceof EventDispatcherInterface) {
$event_dispatcher = \Drupal::service('event_dispatcher');
@trigger_error('Calling ' . __METHOD__ . '() without the $event_dispatcher argument is deprecated in drupal:10.1.0 and it will be required in drupal:11.0.0. See https://www.drupal.org/node/3242573', E_USER_DEPRECATED);
}
$this->eventDispatcher = $event_dispatcher;
$logger = func_get_arg(5);
if (!$logger instanceof LoggerInterface) {
$logger = \Drupal::service('logger.channel.workspaces');
@trigger_error('Calling ' . __METHOD__ . '() without the $logger argument is deprecated in drupal:10.1.0 and it will be required in drupal:11.0.0. See https://www.drupal.org/node/2932520', E_USER_DEPRECATED);
}
$this->logger = $logger;
}
/**
* Gets the workspace publisher.
*
* @param \Drupal\workspaces\WorkspaceInterface $source
* A workspace entity.
*
* @return \Drupal\workspaces\WorkspacePublisherInterface
* A workspace publisher object.
*/
public function getPublisher(WorkspaceInterface $source) {
return new WorkspacePublisher($this->entityTypeManager, $this->database, $this->workspaceManager, $this->workspaceAssociation, $this->eventDispatcher, $source, $this->logger);
}
/**
* Gets the workspace merger.
*
* @param \Drupal\workspaces\WorkspaceInterface $source
* The source workspace entity.
* @param \Drupal\workspaces\WorkspaceInterface $target
* The target workspace entity.
*
* @return \Drupal\workspaces\WorkspaceMergerInterface
* A workspace merger object.
*/
public function getMerger(WorkspaceInterface $source, WorkspaceInterface $target) {
return new WorkspaceMerger($this->entityTypeManager, $this->database, $this->workspaceAssociation, $source, $target, $this->logger);
}
}

View File

@@ -0,0 +1,82 @@
<?php
namespace Drupal\workspaces;
/**
* Defines an interface for workspace operations.
*
* Example operations are publishing, merging and syncing with a remote
* workspace.
*
* @internal
*/
interface WorkspaceOperationInterface {
/**
* Returns the human-readable label of the source.
*
* @return string
* The source label.
*/
public function getSourceLabel();
/**
* Returns the human-readable label of the target.
*
* @return string
* The target label.
*/
public function getTargetLabel();
/**
* Checks if there are any conflicts between the source and the target.
*
* @return array
* Returns an array consisting of the number of conflicts between the source
* and the target, keyed by the conflict type constant.
*/
public function checkConflictsOnTarget();
/**
* Gets the revision identifiers for items which have changed on the target.
*
* @return array
* A multidimensional array of revision identifiers, keyed by entity type
* IDs.
*/
public function getDifferringRevisionIdsOnTarget();
/**
* Gets the revision identifiers for items which have changed on the source.
*
* @return array
* A multidimensional array of revision identifiers, keyed by entity type
* IDs.
*/
public function getDifferringRevisionIdsOnSource();
/**
* Gets the total number of items which have changed on the target.
*
* This returns the aggregated changes count across all entity types.
* For example, if two nodes and one taxonomy term have changed on the target,
* the return value is 3.
*
* @return int
* The number of differing revisions.
*/
public function getNumberOfChangesOnTarget();
/**
* Gets the total number of items which have changed on the source.
*
* This returns the aggregated changes count across all entity types.
* For example, if two nodes and one taxonomy term have changed on the source,
* the return value is 3.
*
* @return int
* The number of differing revisions.
*/
public function getNumberOfChangesOnSource();
}

View File

@@ -0,0 +1,9 @@
<?php
namespace Drupal\workspaces;
/**
* An exception thrown when a workspace can not be published.
*/
class WorkspacePublishException extends WorkspaceAccessException {
}

View File

@@ -0,0 +1,240 @@
<?php
namespace Drupal\workspaces;
use Drupal\Core\Database\Connection;
use Drupal\Core\Entity\EntityTypeManagerInterface;
use Drupal\Core\StringTranslation\StringTranslationTrait;
use Drupal\Core\Utility\Error;
use Drupal\workspaces\Event\WorkspacePostPublishEvent;
use Drupal\workspaces\Event\WorkspacePrePublishEvent;
use Psr\Log\LoggerInterface;
/**
* Default implementation of the workspace publisher.
*
* @internal
*/
class WorkspacePublisher implements WorkspacePublisherInterface {
use StringTranslationTrait;
/**
* The source workspace entity.
*
* @var \Drupal\workspaces\WorkspaceInterface
*/
protected $sourceWorkspace;
/**
* The entity type manager.
*
* @var \Drupal\Core\Entity\EntityTypeManagerInterface
*/
protected $entityTypeManager;
/**
* The database connection.
*
* @var \Drupal\Core\Database\Connection
*/
protected $database;
/**
* The workspace manager.
*
* @var \Drupal\workspaces\WorkspaceManagerInterface
*/
protected $workspaceManager;
/**
* The workspace association service.
*
* @var \Drupal\workspaces\WorkspaceAssociationInterface
*/
protected $workspaceAssociation;
/**
* The event dispatcher.
*
* @var \Symfony\Contracts\EventDispatcher\EventDispatcherInterface
*/
protected $eventDispatcher;
/**
* Constructs a new WorkspacePublisher.
*
* @param \Drupal\Core\Entity\EntityTypeManagerInterface $entity_type_manager
* The entity type manager.
* @param \Drupal\Core\Database\Connection $database
* Database connection.
* @param \Drupal\workspaces\WorkspaceManagerInterface $workspace_manager
* The workspace manager.
* @param \Drupal\workspaces\WorkspaceAssociationInterface $workspace_association
* The workspace association service.
* @param \Symfony\Contracts\EventDispatcher\EventDispatcherInterface $event_dispatcher
* The event dispatcher.
* @param \Drupal\workspaces\WorkspaceInterface $source
* The source workspace entity.
* @param \Psr\Log\LoggerInterface|null $logger
* The logger.
*/
public function __construct(EntityTypeManagerInterface $entity_type_manager, Connection $database, WorkspaceManagerInterface $workspace_manager, WorkspaceAssociationInterface $workspace_association, $event_dispatcher, ?WorkspaceInterface $source = NULL, protected ?LoggerInterface $logger = NULL) {
$this->entityTypeManager = $entity_type_manager;
$this->database = $database;
$this->workspaceManager = $workspace_manager;
$this->workspaceAssociation = $workspace_association;
if ($event_dispatcher instanceof WorkspaceInterface) {
@trigger_error('Calling WorkspacePublisher::__construct() without the $event_dispatcher argument is deprecated in drupal:10.1.0 and will be required in drupal:11.0.0. See https://www.drupal.org/node/3242573', E_USER_DEPRECATED);
$source = $event_dispatcher;
$event_dispatcher = \Drupal::service('event_dispatcher');
}
$this->eventDispatcher = $event_dispatcher;
$this->sourceWorkspace = $source;
if ($this->logger === NULL) {
@trigger_error('Calling ' . __METHOD__ . '() without the $logger argument is deprecated in drupal:10.1.0 and it will be required in drupal:11.0.0. See https://www.drupal.org/node/2932520', E_USER_DEPRECATED);
$this->logger = \Drupal::service('logger.channel.workspaces');
}
}
/**
* {@inheritdoc}
*/
public function publish() {
if ($this->sourceWorkspace->hasParent()) {
throw new WorkspacePublishException('Only top-level workspaces can be published.');
}
if ($this->checkConflictsOnTarget()) {
throw new WorkspaceConflictException();
}
$tracked_entities = $this->workspaceAssociation->getTrackedEntities($this->sourceWorkspace->id());
$event = new WorkspacePrePublishEvent($this->sourceWorkspace, $tracked_entities);
$this->eventDispatcher->dispatch($event);
if ($event->isPublishingStopped()) {
throw new WorkspacePublishException((string) $event->getPublishingStoppedReason());
}
try {
$transaction = $this->database->startTransaction();
// @todo Handle the publishing of a workspace with a batch operation in
// https://www.drupal.org/node/2958752.
$this->workspaceManager->executeOutsideWorkspace(function () use ($tracked_entities) {
foreach ($tracked_entities as $entity_type_id => $revision_difference) {
$entity_revisions = $this->entityTypeManager->getStorage($entity_type_id)
->loadMultipleRevisions(array_keys($revision_difference));
$default_revisions = $this->entityTypeManager->getStorage($entity_type_id)
->loadMultiple(array_values($revision_difference));
/** @var \Drupal\Core\Entity\ContentEntityInterface $entity */
foreach ($entity_revisions as $entity) {
// When pushing workspace-specific revisions to the default
// workspace (Live), we simply need to mark them as default
// revisions.
$entity->setSyncing(TRUE);
$entity->isDefaultRevision(TRUE);
// The default revision is not workspace-specific anymore.
$field_name = $entity->getEntityType()->getRevisionMetadataKey('workspace');
$entity->{$field_name}->target_id = NULL;
$entity->original = $default_revisions[$entity->id()];
$entity->save();
}
}
});
}
catch (\Exception $e) {
if (isset($transaction)) {
$transaction->rollBack();
}
Error::logException($this->logger, $e);
throw $e;
}
$event = new WorkspacePostPublishEvent($this->sourceWorkspace, $tracked_entities);
$this->eventDispatcher->dispatch($event);
}
/**
* {@inheritdoc}
*/
public function getSourceLabel() {
return $this->sourceWorkspace->label();
}
/**
* {@inheritdoc}
*/
public function getTargetLabel() {
return $this->t('Live');
}
/**
* {@inheritdoc}
*/
public function checkConflictsOnTarget() {
// Nothing to do for now, we can not get to a conflicting state because an
// entity which is being edited in a workspace can not be edited in any
// other workspace.
}
/**
* {@inheritdoc}
*/
public function getDifferringRevisionIdsOnTarget() {
$target_revision_difference = [];
$tracked_entities = $this->workspaceAssociation->getTrackedEntities($this->sourceWorkspace->id());
foreach ($tracked_entities as $entity_type_id => $tracked_revisions) {
$entity_type = $this->entityTypeManager->getDefinition($entity_type_id);
// Get the latest revision IDs for all the entities that are tracked by
// the source workspace.
$query = $this->entityTypeManager
->getStorage($entity_type_id)
->getQuery()
->accessCheck(FALSE)
->condition($entity_type->getKey('id'), $tracked_revisions, 'IN')
->latestRevision();
$result = $query->execute();
// Now we compare the revision IDs which are tracked by the source
// workspace to the latest revision IDs of those entities and the
// difference between these two arrays gives us all the entities which
// have been modified on the target.
if ($revision_difference = array_diff_key($result, $tracked_revisions)) {
$target_revision_difference[$entity_type_id] = $revision_difference;
}
}
return $target_revision_difference;
}
/**
* {@inheritdoc}
*/
public function getDifferringRevisionIdsOnSource() {
// Get the Workspace association revisions which haven't been pushed yet.
return $this->workspaceAssociation->getTrackedEntities($this->sourceWorkspace->id());
}
/**
* {@inheritdoc}
*/
public function getNumberOfChangesOnTarget() {
$total_changes = $this->getDifferringRevisionIdsOnTarget();
return count($total_changes, COUNT_RECURSIVE) - count($total_changes);
}
/**
* {@inheritdoc}
*/
public function getNumberOfChangesOnSource() {
$total_changes = $this->getDifferringRevisionIdsOnSource();
return count($total_changes, COUNT_RECURSIVE) - count($total_changes);
}
}

View File

@@ -0,0 +1,17 @@
<?php
namespace Drupal\workspaces;
/**
* Defines an interface for the workspace publisher.
*
* @internal
*/
interface WorkspacePublisherInterface extends WorkspaceOperationInterface {
/**
* Publishes the contents of a workspace to the default (Live) workspace.
*/
public function publish();
}

View File

@@ -0,0 +1,154 @@
<?php
namespace Drupal\workspaces;
use Drupal\Component\Assertion\Inspector;
use Drupal\Component\Graph\Graph;
use Drupal\Core\Cache\Cache;
use Drupal\Core\Cache\CacheBackendInterface;
use Drupal\Core\Entity\EntityTypeManagerInterface;
/**
* Provides the default workspace tree lookup operations.
*/
class WorkspaceRepository implements WorkspaceRepositoryInterface {
/**
* The entity type manager service.
*
* @var \Drupal\Core\Entity\EntityTypeManagerInterface
*/
protected $entityTypeManager;
/**
* The cache backend used to store the workspace tree.
*
* @var \Drupal\Core\Cache\CacheBackendInterface
*/
protected $cache;
/**
* An array of tree items, keyed by workspace IDs and sorted in tree order.
*
* @var array|null
*/
protected $tree;
/**
* Constructs a new WorkspaceRepository instance.
*
* @param \Drupal\Core\Entity\EntityTypeManagerInterface $entity_type_manager
* The entity type manager service.
* @param \Drupal\Core\Cache\CacheBackendInterface $cache_backend
* The cache backend.
*/
public function __construct(EntityTypeManagerInterface $entity_type_manager, CacheBackendInterface $cache_backend) {
$this->entityTypeManager = $entity_type_manager;
$this->cache = $cache_backend;
}
/**
* {@inheritdoc}
*/
public function loadTree() {
if (!isset($this->tree)) {
$cache = $this->cache->get('workspace_tree');
if ($cache) {
$this->tree = $cache->data;
return $this->tree;
}
/** @var \Drupal\workspaces\WorkspaceInterface[] $workspaces */
$workspaces = $this->entityTypeManager->getStorage('workspace')->loadMultiple();
// First, sort everything alphabetically.
uasort($workspaces, function (WorkspaceInterface $a, WorkspaceInterface $b) {
assert(Inspector::assertStringable($a->label()) && Inspector::assertStringable($b->label()), 'Workspace labels are expected to be a string.');
return strnatcasecmp($a->label(), $b->label());
});
$tree_children = [];
foreach ($workspaces as $workspace_id => $workspace) {
$tree_children[$workspace->parent->target_id][] = $workspace_id;
}
// Keeps track of the parents we have to process, the last entry is used
// for the next processing step. Top-level (root) workspace use NULL as
// the parent, so we need to initialize the list with that value.
$process_parents[] = NULL;
// Loops over the parent entities and adds its children to the tree array.
// Uses a loop instead of a recursion, because it's more efficient.
$tree = [];
while (count($process_parents)) {
$parent = array_pop($process_parents);
if (!empty($tree_children[$parent])) {
$child_id = current($tree_children[$parent]);
do {
if (empty($child_id)) {
break;
}
$tree[$child_id] = $workspaces[$child_id];
if (!empty($tree_children[$child_id])) {
// We have to continue with this parent later.
$process_parents[] = $parent;
// Use the current entity as parent for the next iteration.
$process_parents[] = $child_id;
// Move pointer so that we get the correct entity the next time.
next($tree_children[$parent]);
break;
}
} while ($child_id = next($tree_children[$parent]));
}
}
// Generate a graph object in order to populate the `_depth`, `_ancestors`
// and '_descendants' properties for all the entities.
$graph = [];
foreach ($workspaces as $workspace_id => $workspace) {
$graph[$workspace_id]['edges'] = [];
if (!$workspace->parent->isEmpty()) {
$graph[$workspace_id]['edges'][$workspace->parent->target_id] = TRUE;
}
}
$graph = (new Graph($graph))->searchAndSort();
$this->tree = [];
foreach (array_keys($tree) as $workspace_id) {
$this->tree[$workspace_id] = [
'depth' => count($graph[$workspace_id]['paths']),
'ancestors' => array_keys($graph[$workspace_id]['paths']),
'descendants' => isset($graph[$workspace_id]['reverse_paths']) ? array_keys($graph[$workspace_id]['reverse_paths']) : [],
];
}
// Use the 'workspace_list' entity type cache tag because it will be
// invalidated automatically when a workspace is added, updated or
// deleted.
$this->cache->set('workspace_tree', $this->tree, Cache::PERMANENT, $this->entityTypeManager->getDefinition('workspace')->getListCacheTags());
}
return $this->tree;
}
/**
* {@inheritdoc}
*/
public function getDescendantsAndSelf($workspace_id) {
return array_merge([$workspace_id], $this->loadTree()[$workspace_id]['descendants']);
}
/**
* {@inheritdoc}
*/
public function resetCache() {
$this->cache->invalidate('workspace_tree');
$this->tree = NULL;
return $this;
}
}

View File

@@ -0,0 +1,40 @@
<?php
namespace Drupal\workspaces;
/**
* Provides an interface for workspace tree lookup operations.
*/
interface WorkspaceRepositoryInterface {
/**
* Returns an array of workspaces tree item properties, sorted in tree order.
*
* @return array
* An array of workspace tree item properties, keyed by the workspace IDs.
* The tree item properties are:
* - depth: The depth of the workspace in the tree;
* - ancestors: The ancestor IDs of the workspace;
* - descendants: The descendant IDs of the workspace.
*/
public function loadTree();
/**
* Returns the descendant IDs of the passed-in workspace, including itself.
*
* @param string $workspace_id
* A workspace ID.
*
* @return string[]
* An array of descendant workspace IDs, including the passed-in one.
*/
public function getDescendantsAndSelf($workspace_id);
/**
* Resets the cached workspace tree.
*
* @return $this
*/
public function resetCache();
}

View File

@@ -0,0 +1,183 @@
<?php
namespace Drupal\workspaces;
use Drupal\Core\Entity\EntityChangedInterface;
use Drupal\Core\Entity\EntityListBuilder;
use Drupal\Core\Entity\EntityTypeInterface;
use Drupal\Core\Entity\EntityViewBuilder;
use Drupal\user\EntityOwnerInterface;
use Symfony\Component\DependencyInjection\ContainerInterface;
/**
* Provides a Workspace view builder.
*/
class WorkspaceViewBuilder extends EntityViewBuilder {
/**
* The entity type manager.
*
* @var \Drupal\Core\Entity\EntityTypeManagerInterface
*/
protected $entityTypeManager;
/**
* The workspace association service.
*
* @var \Drupal\workspaces\WorkspaceAssociationInterface
*/
protected $workspaceAssociation;
/**
* The date formatter service.
*
* @var \Drupal\Core\Datetime\DateFormatterInterface
*/
protected $dateFormatter;
/**
* The entity bundle information service.
*
* @var \Drupal\Core\Entity\EntityTypeBundleInfoInterface
*/
protected $bundleInfo;
/**
* The number of entities to display on the workspace manage page.
*/
protected int|false $limit = 50;
/**
* {@inheritdoc}
*/
public static function createInstance(ContainerInterface $container, EntityTypeInterface $entity_type) {
$instance = parent::createInstance($container, $entity_type);
$instance->entityTypeManager = $container->get('entity_type.manager');
$instance->workspaceAssociation = $container->get('workspaces.association');
$instance->dateFormatter = $container->get('date.formatter');
$instance->bundleInfo = $container->get('entity_type.bundle.info');
return $instance;
}
/**
* {@inheritdoc}
*/
public function buildComponents(array &$build, array $entities, array $displays, $view_mode) {
parent::buildComponents($build, $entities, $displays, $view_mode);
$bundle_info = $this->bundleInfo->getAllBundleInfo();
$header = [
'title' => $this->t('Title'),
'type' => $this->t('Type'),
'changed' => $this->t('Last changed'),
'owner' => $this->t('Author'),
'operations' => $this->t('Operations'),
];
foreach ($entities as $build_id => $entity) {
// Display the number of entities changed in the workspace regardless of
// how many of them are listed on each page.
$changes_count = [];
$all_tracked_entities = $this->workspaceAssociation->getTrackedEntities($entity->id());
foreach ($all_tracked_entities as $entity_type_id => $tracked_entity_ids) {
$changes_count[$entity_type_id] = $this->entityTypeManager->getDefinition($entity_type_id)->getCountLabel(count($tracked_entity_ids));
}
$build[$build_id]['changes']['overview'] = [
'#type' => 'item',
'#title' => $this->t('Workspace changes'),
];
$build[$build_id]['changes']['list'] = [
'#type' => 'table',
'#header' => $header,
'#empty' => $this->t('This workspace has no changes.'),
];
$paged_tracked_entities = $this->workspaceAssociation->getTrackedEntitiesForListing($entity->id(), $build_id, $this->limit);
foreach ($paged_tracked_entities as $entity_type_id => $tracked_entities) {
$entity_type = $this->entityTypeManager->getDefinition($entity_type_id);
if ($this->entityTypeManager->hasHandler($entity_type_id, 'list_builder')) {
$list_builder = $this->entityTypeManager->getListBuilder($entity_type_id);
}
else {
$list_builder = $this->entityTypeManager->createHandlerInstance(EntityListBuilder::class, $entity_type);
}
$revisions = $this->entityTypeManager->getStorage($entity_type_id)->loadMultipleRevisions(array_keys($tracked_entities));
// Load all users at once.
$user_ids = [];
foreach ($revisions as $revision) {
if ($revision instanceof EntityOwnerInterface) {
$user_ids[$revision->getOwnerId()] = $revision->getOwnerId();
}
}
if ($user_ids = array_filter($user_ids)) {
$revision_owners = $this->entityTypeManager->getStorage('user')->loadMultiple($user_ids);
}
foreach ($revisions as $revision) {
if ($revision->getEntityType()->hasLinkTemplate('canonical')) {
$title = [
'#type' => 'link',
'#title' => $revision->label(),
'#url' => $revision->toUrl(),
];
}
else {
$title = ['#markup' => $revision->label()];
}
if (count($bundle_info[$entity_type_id]) > 1) {
$type = [
'#markup' => $this->t('@entity_type_label: @entity_bundle_label', [
'@entity_type_label' => $entity_type->getLabel(),
'@entity_bundle_label' => $bundle_info[$entity_type_id][$revision->bundle()]['label'],
]),
];
}
else {
$type = ['#markup' => $bundle_info[$entity_type_id][$revision->bundle()]['label']];
}
$changed = $revision instanceof EntityChangedInterface
? $this->dateFormatter->format($revision->getChangedTime())
: '';
if ($revision instanceof EntityOwnerInterface && isset($revision_owners[$revision->getOwnerId()])) {
$author = [
'#theme' => 'username',
'#account' => $revision_owners[$revision->getOwnerId()],
];
}
else {
$author = ['#markup' => ''];
}
$build[$build_id]['changes']['list'][$entity_type_id . ':' . $revision->id()] = [
'#entity' => $revision,
'title' => $title,
'type' => $type,
'changed' => ['#markup' => $changed],
'owner' => $author,
'operations' => [
'#type' => 'operations',
'#links' => $list_builder->getOperations($revision),
],
];
}
}
if ($changes_count) {
$build[$build_id]['changes']['overview']['#markup'] = implode(', ', $changes_count);
}
$build[$build_id]['pager'] = [
'#type' => 'pager',
'#element' => $build_id,
];
}
}
}

View File

@@ -0,0 +1,59 @@
<?php
declare(strict_types=1);
namespace Drupal\workspaces;
use Drupal\path_alias\AliasManagerInterface;
/**
* Decorates the path_alias.manager service for workspace-specific caching.
*
* @internal
*/
class WorkspacesAliasManager implements AliasManagerInterface {
public function __construct(
protected readonly AliasManagerInterface $inner,
protected readonly WorkspaceManagerInterface $workspaceManager,
) {}
/**
* {@inheritdoc}
*/
public function setCacheKey($key): void {
if ($this->workspaceManager->hasActiveWorkspace()) {
$key = $this->workspaceManager->getActiveWorkspace()->id() . ':' . $key;
}
$this->inner->setCacheKey($key);
}
/**
* {@inheritdoc}
*/
public function writeCache(): void {
$this->inner->writeCache();
}
/**
* {@inheritdoc}
*/
public function getPathByAlias($alias, $langcode = NULL): string {
return $this->inner->getPathByAlias($alias, $langcode);
}
/**
* {@inheritdoc}
*/
public function getAliasByPath($path, $langcode = NULL): string {
return $this->inner->getAliasByPath($path, $langcode);
}
/**
* {@inheritdoc}
*/
public function cacheClear($source = NULL): void {
$this->inner->cacheClear($source);
}
}

View File

@@ -0,0 +1,52 @@
<?php
namespace Drupal\workspaces;
use Drupal\path_alias\AliasRepository;
/**
* Provides workspace-specific path alias lookup queries.
*/
class WorkspacesAliasRepository extends AliasRepository {
/**
* The workspace manager.
*
* @var \Drupal\workspaces\WorkspaceManagerInterface
*/
protected $workspaceManager;
/**
* Sets the workspace manager.
*
* @param \Drupal\workspaces\WorkspaceManagerInterface $workspace_manager
* The workspace manager service.
*
* @return $this
*/
public function setWorkspacesManager(WorkspaceManagerInterface $workspace_manager) {
$this->workspaceManager = $workspace_manager;
return $this;
}
/**
* {@inheritdoc}
*/
protected function getBaseQuery() {
// Don't alter any queries if we're not in a workspace context.
if (!$this->workspaceManager->hasActiveWorkspace()) {
return parent::getBaseQuery();
}
$active_workspace = $this->workspaceManager->getActiveWorkspace();
$query = $this->connection->select('path_alias', 'base_table_2');
$wa_join = $query->leftJoin('workspace_association', NULL, "[%alias].[target_entity_type_id] = 'path_alias' AND [%alias].[target_entity_id] = [base_table_2].[id] AND [%alias].[workspace] = :active_workspace_id", [
':active_workspace_id' => $active_workspace->id(),
]);
$query->innerJoin('path_alias_revision', 'base_table', "[%alias].[revision_id] = COALESCE([$wa_join].[target_entity_revision_id], [base_table_2].[revision_id])");
return $query;
}
}

View File

@@ -0,0 +1,87 @@
<?php
declare(strict_types=1);
namespace Drupal\workspaces;
use Drupal\Component\Serialization\Json;
use Drupal\Core\Render\ElementInfoManagerInterface;
use Drupal\Core\Security\TrustedCallbackInterface;
use Drupal\Core\StringTranslation\StringTranslationTrait;
use Drupal\Core\Url;
/**
* Defines a service for workspaces #lazy_builder callbacks.
*
* @internal
*/
final class WorkspacesLazyBuilders implements TrustedCallbackInterface {
use StringTranslationTrait;
public function __construct(
protected readonly WorkspaceManagerInterface $workspaceManager,
protected readonly ElementInfoManagerInterface $elementInfo,
) {}
/**
* Lazy builder callback for rendering the workspace toolbar tab.
*
* @return array
* A render array.
*/
public function renderToolbarTab(): array {
$active_workspace = $this->workspaceManager->getActiveWorkspace();
$build = [
'#type' => 'link',
'#title' => $active_workspace ? $active_workspace->label() : $this->t('Live'),
'#url' => Url::fromRoute('entity.workspace.collection', [], ['query' => \Drupal::destination()->getAsArray()]),
'#attributes' => [
'title' => t('Switch workspace'),
'class' => [
'toolbar-item',
'toolbar-icon',
'toolbar-icon-workspace',
'use-ajax',
],
'data-dialog-type' => 'dialog',
'data-dialog-renderer' => 'off_canvas_top',
'data-dialog-options' => Json::encode([
'height' => 161,
'classes' => [
'ui-dialog' => 'workspaces-dialog',
],
]),
],
'#attached' => [
'library' => ['workspaces/drupal.workspaces.toolbar'],
],
'#cache' => [
'max-age' => 0,
],
];
// The renderer has already added element defaults by the time the lazy
// builder is run.
// @see https://www.drupal.org/project/drupal/issues/2609250
$build += $this->elementInfo->getInfo('link');
return $build;
}
/**
* Render callback for the workspace toolbar tab.
*/
public static function removeTabAttributes(array $element): array {
unset($element['tab']['#attributes']);
return $element;
}
/**
* {@inheritdoc}
*/
public static function trustedCallbacks(): array {
return ['removeTabAttributes', 'renderToolbarTab'];
}
}

View File

@@ -0,0 +1,96 @@
<?php
namespace Drupal\workspaces;
use Drupal\Core\Cache\CacheBackendInterface;
use Drupal\Core\Cache\CacheTagsInvalidatorInterface;
use Drupal\Core\Database\Connection;
use Drupal\Core\Entity\EntityTypeManagerInterface;
use Drupal\Core\Menu\MenuTreeParameters;
use Drupal\Core\Menu\MenuTreeStorage as CoreMenuTreeStorage;
/**
* Overrides the default menu storage to provide workspace-specific menu links.
*
* @internal
*/
class WorkspacesMenuTreeStorage extends CoreMenuTreeStorage {
/**
* WorkspacesMenuTreeStorage constructor.
*
* @param \Drupal\workspaces\WorkspaceManagerInterface $workspaceManager
* The workspace manager service.
* @param \Drupal\workspaces\WorkspaceAssociationInterface $workspaceAssociation
* The workspace association service.
* @param \Drupal\Core\Entity\EntityTypeManagerInterface $entityTypeManager
* The entity type manager.
* @param \Drupal\Core\Database\Connection $connection
* A Database connection to use for reading and writing configuration data.
* @param \Drupal\Core\Cache\CacheBackendInterface $menu_cache_backend
* Cache backend instance for the extracted tree data.
* @param \Drupal\Core\Cache\CacheTagsInvalidatorInterface $cache_tags_invalidator
* The cache tags invalidator.
* @param string $table
* A database table name to store configuration data in.
* @param array $options
* (optional) Any additional database connection options to use in queries.
*/
public function __construct(
protected readonly WorkspaceManagerInterface $workspaceManager,
protected readonly WorkspaceAssociationInterface $workspaceAssociation,
protected readonly EntityTypeManagerInterface $entityTypeManager,
Connection $connection,
CacheBackendInterface $menu_cache_backend,
CacheTagsInvalidatorInterface $cache_tags_invalidator,
string $table,
array $options = [],
) {
parent::__construct($connection, $menu_cache_backend, $cache_tags_invalidator, $table, $options);
}
/**
* {@inheritdoc}
*/
public function loadTreeData($menu_name, MenuTreeParameters $parameters) {
// Add the active workspace as a menu tree condition parameter in order to
// include it in the cache ID.
if ($active_workspace = $this->workspaceManager->getActiveWorkspace()) {
$parameters->conditions['workspace'] = $active_workspace->id();
}
return parent::loadTreeData($menu_name, $parameters);
}
/**
* {@inheritdoc}
*/
protected function loadLinks($menu_name, MenuTreeParameters $parameters) {
$links = parent::loadLinks($menu_name, $parameters);
// Replace the menu link plugin definitions with workspace-specific ones.
if ($active_workspace = $this->workspaceManager->getActiveWorkspace()) {
$tracked_revisions = $this->workspaceAssociation->getTrackedEntities($active_workspace->id());
if (isset($tracked_revisions['menu_link_content'])) {
/** @var \Drupal\menu_link_content\MenuLinkContentInterface[] $workspace_revisions */
$workspace_revisions = $this->entityTypeManager->getStorage('menu_link_content')->loadMultipleRevisions(array_keys($tracked_revisions['menu_link_content']));
foreach ($workspace_revisions as $workspace_revision) {
if (isset($links[$workspace_revision->getPluginId()])) {
$pending_plugin_definition = $workspace_revision->getPluginDefinition();
$links[$workspace_revision->getPluginId()] = [
'title' => serialize($pending_plugin_definition['title']),
'description' => serialize($pending_plugin_definition['description']),
'enabled' => (string) $pending_plugin_definition['enabled'],
'url' => $pending_plugin_definition['url'],
'route_name' => $pending_plugin_definition['route_name'],
'route_parameters' => serialize($pending_plugin_definition['route_parameters']),
'options' => serialize($pending_plugin_definition['options']),
] + $links[$workspace_revision->getPluginId()];
}
}
}
}
return $links;
}
}

View File

@@ -0,0 +1,54 @@
<?php
namespace Drupal\workspaces;
use Drupal\Core\DependencyInjection\ContainerBuilder;
use Drupal\Core\DependencyInjection\ServiceProviderBase;
use Drupal\Core\Update\UpdateKernel;
use Symfony\Component\DependencyInjection\Reference;
/**
* Defines a service provider for the Workspaces module.
*/
class WorkspacesServiceProvider extends ServiceProviderBase {
/**
* {@inheritdoc}
*/
public function alter(ContainerBuilder $container) {
// Add the 'workspace' cache context as required.
$renderer_config = $container->getParameter('renderer.config');
$renderer_config['required_cache_contexts'][] = 'workspace';
$container->setParameter('renderer.config', $renderer_config);
// Decorate the 'path_alias.manager' service.
if ($container->hasDefinition('path_alias.manager')) {
$container->register('workspaces.path_alias.manager', WorkspacesAliasManager::class)
->setPublic(FALSE)
->setDecoratedService('path_alias.manager', NULL, 50)
->addArgument(new Reference('workspaces.path_alias.manager.inner'))
->addArgument(new Reference('workspaces.manager'));
}
// Replace the class of the 'path_alias.repository' service.
if ($container->hasDefinition('path_alias.repository')) {
$definition = $container->getDefinition('path_alias.repository');
if (!$definition->isDeprecated()) {
$definition
->setClass(WorkspacesAliasRepository::class)
->addMethodCall('setWorkspacesManager', [new Reference('workspaces.manager')]);
}
}
// Ensure that there's no active workspace while running database updates by
// removing the relevant tag from all workspace negotiator services.
if ($container->get('kernel') instanceof UpdateKernel) {
foreach ($container->getDefinitions() as $definition) {
if ($definition->hasTag('workspace_negotiator')) {
$definition->clearTag('workspace_negotiator');
}
}
}
}
}

View File

@@ -0,0 +1,12 @@
name: 'Workspace Access Test'
type: module
description: 'Provides supporting code for testing access for workspaces.'
package: Testing
# version: VERSION
dependencies:
- drupal:workspaces
# Information added by Drupal.org packaging script on 2024-07-04
version: '10.3.1'
project: 'drupal'
datestamp: 1720094222

View File

@@ -0,0 +1,17 @@
<?php
/**
* @file
* Provides supporting code for testing access for workspaces.
*/
use Drupal\Core\Access\AccessResult;
use Drupal\Core\Entity\EntityInterface;
use Drupal\Core\Session\AccountInterface;
/**
* Implements hook_ENTITY_TYPE_access() for the 'workspace' entity type.
*/
function workspace_access_test_workspace_access(EntityInterface $entity, $operation, AccountInterface $account) {
return \Drupal::state()->get("workspace_access_test.result.$operation", AccessResult::neutral());
}

View File

@@ -0,0 +1,51 @@
<?php
namespace Drupal\workspace_update_test\Negotiator;
use Drupal\workspaces\Entity\Workspace;
use Drupal\workspaces\Negotiator\WorkspaceIdNegotiatorInterface;
use Drupal\workspaces\Negotiator\WorkspaceNegotiatorInterface;
use Drupal\workspaces\WorkspaceInterface;
use Symfony\Component\HttpFoundation\Request;
/**
* Defines a workspace negotiator used for testing.
*/
class TestWorkspaceNegotiator implements WorkspaceNegotiatorInterface, WorkspaceIdNegotiatorInterface {
/**
* {@inheritdoc}
*/
public function applies(Request $request) {
return TRUE;
}
/**
* {@inheritdoc}
*/
public function getActiveWorkspaceId(Request $request): ?string {
return 'test';
}
/**
* {@inheritdoc}
*/
public function getActiveWorkspace(Request $request) {
return Workspace::load($this->getActiveWorkspaceId($request));
}
/**
* {@inheritdoc}
*/
public function setActiveWorkspace(WorkspaceInterface $workspace) {
// Nothing to do here.
}
/**
* {@inheritdoc}
*/
public function unsetActiveWorkspace() {
// Nothing to do here.
}
}

View File

@@ -0,0 +1,12 @@
name: 'Workspace Update Test'
type: module
description: 'Provides supporting code for testing workspaces during database updates.'
package: Testing
# version: VERSION
dependencies:
- drupal:workspaces
# Information added by Drupal.org packaging script on 2024-07-04
version: '10.3.1'
project: 'drupal'
datestamp: 1720094222

View File

@@ -0,0 +1,13 @@
<?php
/**
* @file
* Post update functions for the Workspace Update Test module.
*/
/**
* Checks the active workspace during database updates.
*/
function workspace_update_test_post_update_check_active_workspace() {
\Drupal::state()->set('workspace_update_test.has_active_workspace', \Drupal::service('workspaces.manager')->hasActiveWorkspace());
}

View File

@@ -0,0 +1,5 @@
services:
workspace_update_test.negotiator.test:
class: Drupal\workspace_update_test\Negotiator\TestWorkspaceNegotiator
tags:
- { name: workspace_negotiator, priority: 0 }

View File

@@ -0,0 +1,20 @@
<?php
namespace Drupal\workspaces_test;
use Drupal\Core\Entity\EntityInterface;
use Drupal\workspaces\Entity\Handler\DefaultWorkspaceHandler;
/**
* Provides a custom workspace handler for testing purposes.
*/
class EntityTestRevPubWorkspaceHandler extends DefaultWorkspaceHandler {
/**
* {@inheritdoc}
*/
public function isEntitySupported(EntityInterface $entity): bool {
return $entity->bundle() !== 'ignored_bundle';
}
}

View File

@@ -0,0 +1,12 @@
name: 'Workspace Test'
type: module
description: 'Provides supporting code for testing workspaces.'
package: Testing
# version: VERSION
dependencies:
- drupal:workspaces
# Information added by Drupal.org packaging script on 2024-07-04
version: '10.3.1'
project: 'drupal'
datestamp: 1720094222

View File

@@ -0,0 +1,19 @@
<?php
/**
* @file
* Provides supporting code for testing workspaces.
*/
/**
* Implements hook_entity_type_alter().
*/
function workspaces_test_entity_type_alter(array &$entity_types) {
$state = \Drupal::state();
// Allow all entity types to have their definition changed dynamically for
// testing purposes.
foreach ($entity_types as $entity_type_id => $entity_type) {
$entity_types[$entity_type_id] = $state->get("$entity_type_id.entity_type", $entity_types[$entity_type_id]);
}
}

View File

@@ -0,0 +1,25 @@
<?php
declare(strict_types=1);
namespace Drupal\Tests\workspaces\Functional;
use Drupal\Tests\system\Functional\Module\GenericModuleTestBase;
/**
* Generic module test for workspaces.
*
* @group workspaces
*/
class GenericTest extends GenericModuleTestBase {
/**
* {@inheritdoc}
*/
protected function preUninstallSteps(): void {
$storage = \Drupal::entityTypeManager()->getStorage('workspace');
$workspaces = $storage->loadMultiple();
$storage->delete($workspaces);
}
}

View File

@@ -0,0 +1,345 @@
<?php
declare(strict_types=1);
namespace Drupal\Tests\workspaces\Functional;
use Drupal\Tests\BrowserTestBase;
use Drupal\Tests\content_translation\Traits\ContentTranslationTestTrait;
use Drupal\Tests\WaitTerminateTestTrait;
use Drupal\workspaces\Entity\Workspace;
/**
* Tests path aliases with workspaces.
*
* @group path
* @group workspaces
*/
class PathWorkspacesTest extends BrowserTestBase {
use ContentTranslationTestTrait;
use WorkspaceTestUtilities;
use WaitTerminateTestTrait;
/**
* {@inheritdoc}
*/
protected static $modules = [
'block',
'content_translation',
'node',
'path',
'workspaces',
];
/**
* {@inheritdoc}
*/
protected $defaultTheme = 'stark';
/**
* {@inheritdoc}
*/
protected function setUp(): void {
parent::setUp();
static::createLanguageFromLangcode('ro');
$this->rebuildContainer();
// Create a content type.
$this->drupalCreateContentType([
'name' => 'article',
'type' => 'article',
]);
$permissions = [
'administer languages',
'administer nodes',
'administer url aliases',
'administer workspaces',
'create article content',
'create content translations',
'edit any article content',
'translate any entity',
];
$this->drupalLogin($this->drupalCreateUser($permissions));
// Enable URL language detection and selection.
$edit = ['language_interface[enabled][language-url]' => 1];
$this->drupalGet('admin/config/regional/language/detection');
$this->submitForm($edit, 'Save settings');
// Enable translation for article node.
static::enableContentTranslation('node', 'article');
$this->setupWorkspaceSwitcherBlock();
// The \Drupal\path_alias\AliasWhitelist service performs cache clears after
// Drupal has flushed the response to the client. We use
// WaitTerminateTestTrait to wait for Drupal to do this before continuing.
$this->setWaitForTerminate();
}
/**
* Tests path aliases with workspaces.
*/
public function testPathAliases(): void {
// Create a published node in Live, without an alias.
$node = $this->drupalCreateNode([
'type' => 'article',
'status' => TRUE,
]);
// Switch to Stage and create an alias for the node.
$stage = Workspace::load('stage');
$this->switchToWorkspace($stage);
$edit = [
'path[0][alias]' => '/' . $this->randomMachineName(),
];
$this->drupalGet('node/' . $node->id() . '/edit');
$this->submitForm($edit, 'Save');
// Check that the node can be accessed in Stage with the given alias.
$path = $edit['path[0][alias]'];
$this->assertAccessiblePaths([$path]);
// Check that the 'preload-paths' cache includes the active workspace ID in
// the cache key.
$this->assertNotEmpty(\Drupal::cache('data')->get('preload-paths:stage:/node/1'));
$this->assertFalse(\Drupal::cache('data')->get('preload-paths:/node/1'));
// Check that the alias can not be accessed in Live.
$this->switchToLive();
$this->assertNotAccessiblePaths([$path]);
$this->assertFalse(\Drupal::cache('data')->get('preload-paths:/node/1'));
// Publish the workspace and check that the alias can be accessed in Live.
$stage->publish();
$this->assertAccessiblePaths([$path]);
$this->assertNotEmpty(\Drupal::cache('data')->get('preload-paths:/node/1'));
}
/**
* Tests path aliases with workspaces and user switching.
*/
public function testPathAliasesUserSwitch(): void {
// Create a published node in Live, without an alias.
$node = $this->drupalCreateNode([
'type' => 'article',
'status' => TRUE,
]);
// Switch to Stage and create an alias for the node.
$stage = Workspace::load('stage');
$this->switchToWorkspace($stage);
$edit = [
'path[0][alias]' => '/' . $this->randomMachineName(),
];
$this->drupalGet('node/' . $node->id() . '/edit');
$this->submitForm($edit, 'Save');
// Check that the node can be accessed in Stage with the given alias.
$path = $edit['path[0][alias]'];
$this->assertAccessiblePaths([$path]);
// Check that the 'preload-paths' cache includes the active workspace ID in
// the cache key.
$this->assertNotEmpty(\Drupal::cache('data')->get('preload-paths:stage:/node/1'));
$this->assertFalse(\Drupal::cache('data')->get('preload-paths:/node/1'));
// Check that the alias can not be accessed in Live, by logging out without
// an explicit switch.
$this->drupalLogout();
$this->assertNotAccessiblePaths([$path]);
$this->assertFalse(\Drupal::cache('data')->get('preload-paths:/node/1'));
// Publish the workspace and check that the alias can be accessed in Live.
$this->drupalLogin($this->rootUser);
$stage->publish();
$this->drupalLogout();
$this->assertAccessiblePaths([$path]);
$this->assertNotEmpty(\Drupal::cache('data')->get('preload-paths:/node/1'));
}
/**
* Tests path aliases with workspaces for translatable nodes.
*/
public function testPathAliasesWithTranslation(): void {
$stage = Workspace::load('stage');
// Create one node with a random alias.
$default_node = $this->drupalCreateNode([
'type' => 'article',
'langcode' => 'en',
'status' => TRUE,
'path' => '/' . $this->randomMachineName(),
]);
// Add published translation with another alias.
$this->drupalGet('node/' . $default_node->id());
$this->drupalGet('node/' . $default_node->id() . '/translations');
$this->clickLink('Add');
$edit_translation = [
'body[0][value]' => $this->randomMachineName(),
'status[value]' => TRUE,
'path[0][alias]' => '/' . $this->randomMachineName(),
];
$this->submitForm($edit_translation, 'Save (this translation)');
// Confirm that the alias works.
$this->drupalGet('ro' . $edit_translation['path[0][alias]']);
$this->assertSession()->pageTextContains($edit_translation['body[0][value]']);
$default_path = $default_node->path->alias;
$translation_path = 'ro' . $edit_translation['path[0][alias]'];
$this->assertAccessiblePaths([$default_path, $translation_path]);
$this->switchToWorkspace($stage);
$this->assertAccessiblePaths([$default_path, $translation_path]);
// Create a workspace-specific revision for the translation with a new path
// alias.
$edit_new_translation_draft_with_alias = [
'path[0][alias]' => '/' . $this->randomMachineName(),
];
$this->drupalGet('ro/node/' . $default_node->id() . '/edit');
$this->submitForm($edit_new_translation_draft_with_alias, 'Save (this translation)');
$stage_translation_path = 'ro' . $edit_new_translation_draft_with_alias['path[0][alias]'];
// The new alias of the translation should be available in Stage, but not
// available in Live.
$this->assertAccessiblePaths([$default_path, $stage_translation_path]);
// Check that the previous (Live) path alias no longer works.
$this->assertNotAccessiblePaths([$translation_path]);
// Switch out of Stage and check that the initial path aliases still work.
$this->switchToLive();
$this->assertAccessiblePaths([$default_path, $translation_path]);
$this->assertNotAccessiblePaths([$stage_translation_path]);
// Switch back to Stage.
$this->switchToWorkspace($stage);
// Create new workspace-specific revision for translation without changing
// the path alias.
$edit_new_translation_draft = [
'body[0][value]' => $this->randomMachineName(),
];
$this->drupalGet('ro/node/' . $default_node->id() . '/edit');
$this->submitForm($edit_new_translation_draft, 'Save (this translation)');
// Confirm that the new draft revision was created.
$this->assertSession()->pageTextContains($edit_new_translation_draft['body[0][value]']);
// Switch out of Stage and check that the initial path aliases still work.
$this->switchToLive();
$this->assertAccessiblePaths([$default_path, $translation_path]);
$this->assertNotAccessiblePaths([$stage_translation_path]);
// Switch back to Stage.
$this->switchToWorkspace($stage);
$this->assertAccessiblePaths([$default_path, $stage_translation_path]);
$this->assertNotAccessiblePaths([$translation_path]);
// Create a new workspace-specific revision for translation with path alias
// from the original language's default revision.
$edit_new_translation_draft_with_defaults_alias = [
'path[0][alias]' => $default_node->path->alias,
];
$this->drupalGet('ro/node/' . $default_node->id() . '/edit');
$this->submitForm($edit_new_translation_draft_with_defaults_alias, 'Save (this translation)');
// Switch out of Stage and check that the initial path aliases still work.
$this->switchToLive();
$this->assertAccessiblePaths([$default_path, $translation_path]);
$this->assertNotAccessiblePaths([$stage_translation_path]);
// Check that only one path alias (the original one) is available in Stage.
$this->switchToWorkspace($stage);
$this->assertAccessiblePaths([$default_path]);
$this->assertNotAccessiblePaths([$translation_path, $stage_translation_path]);
// Create new workspace-specific revision for translation with a deleted
// (empty) path alias.
$edit_new_translation_draft_empty_alias = [
'body[0][value]' => $this->randomMachineName(),
'path[0][alias]' => '',
];
$this->drupalGet('ro/node/' . $default_node->id() . '/edit');
$this->submitForm($edit_new_translation_draft_empty_alias, 'Save (this translation)');
// Check that only one path alias (the original one) is available now.
$this->switchToLive();
$this->assertAccessiblePaths([$default_path, $translation_path]);
$this->assertNotAccessiblePaths([$stage_translation_path]);
$this->switchToWorkspace($stage);
$this->assertAccessiblePaths([$default_path]);
$this->assertNotAccessiblePaths([$translation_path, $stage_translation_path]);
// Create a new workspace-specific revision for the translation with a new
// path alias.
$edit_new_translation = [
'body[0][value]' => $this->randomMachineName(),
'path[0][alias]' => '/' . $this->randomMachineName(),
];
$this->drupalGet('ro/node/' . $default_node->id() . '/edit');
$this->submitForm($edit_new_translation, 'Save (this translation)');
// Confirm that the new revision was created.
$this->assertSession()->pageTextContains($edit_new_translation['body[0][value]']);
$this->assertSession()->addressEquals('ro' . $edit_new_translation['path[0][alias]']);
// Check that only the new path alias of the translation can be accessed.
$new_stage_translation_path = 'ro' . $edit_new_translation['path[0][alias]'];
$this->assertAccessiblePaths([$default_path, $new_stage_translation_path]);
$this->assertNotAccessiblePaths([$stage_translation_path]);
// Switch out of Stage and check that none of the workspace-specific path
// aliases can be accessed.
$this->switchToLive();
$this->assertAccessiblePaths([$default_path, $translation_path]);
$this->assertNotAccessiblePaths([$stage_translation_path, $new_stage_translation_path]);
// Publish Stage and check that its path alias for the translation can be
// accessed.
$stage->publish();
$this->assertAccessiblePaths([$default_path, $new_stage_translation_path]);
$this->assertNotAccessiblePaths([$stage_translation_path]);
}
/**
* Helper callback to verify paths are responding with status 200.
*
* @param string[] $paths
* An array of paths to check for.
*
* @internal
*/
protected function assertAccessiblePaths(array $paths): void {
foreach ($paths as $path) {
$this->drupalGet($path);
$this->assertSession()->statusCodeEquals(200);
}
}
/**
* Helper callback to verify paths are responding with status 404.
*
* @param string[] $paths
* An array of paths to check for.
*
* @internal
*/
protected function assertNotAccessiblePaths(array $paths): void {
foreach ($paths as $path) {
$this->drupalGet($path);
$this->assertSession()->statusCodeEquals(404);
}
}
}

View File

@@ -0,0 +1,33 @@
<?php
declare(strict_types=1);
namespace Drupal\Tests\workspaces\Functional\Rest;
use Drupal\Tests\rest\Functional\AnonResourceTestTrait;
/**
* Test workspace entities for unauthenticated JSON requests.
*
* @group workspaces
*/
class WorkspaceJsonAnonTest extends WorkspaceResourceTestBase {
use AnonResourceTestTrait;
/**
* {@inheritdoc}
*/
protected static $format = 'json';
/**
* {@inheritdoc}
*/
protected static $mimeType = 'application/json';
/**
* {@inheritdoc}
*/
protected $defaultTheme = 'stark';
}

View File

@@ -0,0 +1,43 @@
<?php
declare(strict_types=1);
namespace Drupal\Tests\workspaces\Functional\Rest;
use Drupal\Tests\rest\Functional\BasicAuthResourceTestTrait;
/**
* Test workspace entities for JSON requests via basic auth.
*
* @group workspaces
*/
class WorkspaceJsonBasicAuthTest extends WorkspaceResourceTestBase {
use BasicAuthResourceTestTrait;
/**
* {@inheritdoc}
*/
protected static $modules = ['basic_auth'];
/**
* {@inheritdoc}
*/
protected $defaultTheme = 'stark';
/**
* {@inheritdoc}
*/
protected static $format = 'json';
/**
* {@inheritdoc}
*/
protected static $mimeType = 'application/json';
/**
* {@inheritdoc}
*/
protected static $auth = 'basic_auth';
}

View File

@@ -0,0 +1,38 @@
<?php
declare(strict_types=1);
namespace Drupal\Tests\workspaces\Functional\Rest;
use Drupal\Tests\rest\Functional\CookieResourceTestTrait;
/**
* Test workspace entities for JSON requests with cookie authentication.
*
* @group workspaces
*/
class WorkspaceJsonCookieTest extends WorkspaceResourceTestBase {
use CookieResourceTestTrait;
/**
* {@inheritdoc}
*/
protected static $format = 'json';
/**
* {@inheritdoc}
*/
protected static $mimeType = 'application/json';
/**
* {@inheritdoc}
*/
protected static $auth = 'cookie';
/**
* {@inheritdoc}
*/
protected $defaultTheme = 'stark';
}

View File

@@ -0,0 +1,205 @@
<?php
declare(strict_types=1);
namespace Drupal\Tests\workspaces\Functional\Rest;
use Drupal\Tests\rest\Functional\EntityResource\EntityResourceTestBase;
use Drupal\user\Entity\User;
use Drupal\workspaces\Entity\Workspace;
/**
* Base class for workspace EntityResource tests.
*/
abstract class WorkspaceResourceTestBase extends EntityResourceTestBase {
/**
* {@inheritdoc}
*/
protected static $modules = ['workspaces'];
/**
* {@inheritdoc}
*/
protected static $entityTypeId = 'workspace';
/**
* {@inheritdoc}
*/
protected static $patchProtectedFieldNames = [
'changed' => NULL,
];
/**
* {@inheritdoc}
*/
protected static $uniqueFieldNames = [
'id',
];
/**
* {@inheritdoc}
*/
protected static $firstCreatedEntityId = 'running_on_faith';
/**
* {@inheritdoc}
*/
protected static $secondCreatedEntityId = 'running_on_faith_2';
/**
* {@inheritdoc}
*/
protected function setUpAuthorization($method) {
switch ($method) {
case 'GET':
$this->grantPermissionsToTestedRole(['view any workspace']);
break;
case 'POST':
$this->grantPermissionsToTestedRole(['create workspace']);
break;
case 'PATCH':
$this->grantPermissionsToTestedRole(['edit any workspace']);
break;
case 'DELETE':
$this->grantPermissionsToTestedRole(['delete any workspace']);
break;
}
}
/**
* {@inheritdoc}
*/
protected function createEntity() {
$workspace = Workspace::create([
'id' => 'layla',
'label' => 'Layla',
]);
$workspace->save();
return $workspace;
}
/**
* {@inheritdoc}
*/
protected function createAnotherEntity() {
$workspace = $this->entity->createDuplicate();
$workspace->id = 'layla_dupe';
$workspace->label = 'Layla_dupe';
$workspace->save();
return $workspace;
}
/**
* {@inheritdoc}
*/
protected function getExpectedNormalizedEntity() {
$author = User::load($this->entity->getOwnerId());
return [
'created' => [
[
'value' => (new \DateTime())->setTimestamp((int) $this->entity->getCreatedTime())->setTimezone(new \DateTimeZone('UTC'))->format(\DateTime::RFC3339),
'format' => \DateTime::RFC3339,
],
],
'changed' => [
[
'value' => (new \DateTime())->setTimestamp($this->entity->getChangedTime())->setTimezone(new \DateTimeZone('UTC'))->format(\DateTime::RFC3339),
'format' => \DateTime::RFC3339,
],
],
'id' => [
[
'value' => 'layla',
],
],
'label' => [
[
'value' => 'Layla',
],
],
'revision_id' => [
[
'value' => 2,
],
],
'parent' => [],
'uid' => [
[
'target_id' => (int) $author->id(),
'target_type' => 'user',
'target_uuid' => $author->uuid(),
'url' => base_path() . 'user/' . $author->id(),
],
],
'uuid' => [
[
'value' => $this->entity->uuid(),
],
],
];
}
/**
* {@inheritdoc}
*/
protected function getNormalizedPostEntity() {
return [
'id' => [
[
'value' => static::$firstCreatedEntityId,
],
],
'label' => [
[
'value' => 'Running on faith',
],
],
];
}
/**
* {@inheritdoc}
*/
protected function getNormalizedPatchEntity() {
return array_diff_key($this->getNormalizedPostEntity(), ['id' => TRUE]);
}
/**
* {@inheritdoc}
*/
protected function getExpectedUnauthorizedAccessMessage($method) {
switch ($method) {
case 'GET':
return "The 'view any workspace' permission is required.";
case 'POST':
return "The following permissions are required: 'administer workspaces' OR 'create workspace'.";
case 'PATCH':
return "The 'edit any workspace' permission is required.";
case 'DELETE':
return "The 'delete any workspace' permission is required.";
}
return parent::getExpectedUnauthorizedAccessMessage($method);
}
/**
* {@inheritdoc}
*/
protected function getModifiedEntityForPostTesting() {
$modified = parent::getModifiedEntityForPostTesting();
// Even though the field type of the workspace ID is 'string', it acts as a
// machine name through a custom constraint, so we need to ensure that we
// generate a proper random value for it.
// @see \Drupal\workspaces\Entity\Workspace::baseFieldDefinitions()
$modified['id'] = [$this->randomMachineName()];
return $modified;
}
}

View File

@@ -0,0 +1,43 @@
<?php
declare(strict_types=1);
namespace Drupal\Tests\workspaces\Functional\Rest;
use Drupal\Tests\rest\Functional\AnonResourceTestTrait;
use Drupal\Tests\rest\Functional\EntityResource\XmlEntityNormalizationQuirksTrait;
/**
* Test workspace entities for unauthenticated XML requests.
*
* @group workspaces
*/
class WorkspaceXmlAnonTest extends WorkspaceResourceTestBase {
use AnonResourceTestTrait;
use XmlEntityNormalizationQuirksTrait;
/**
* {@inheritdoc}
*/
protected static $format = 'xml';
/**
* {@inheritdoc}
*/
protected static $mimeType = 'text/xml; charset=UTF-8';
/**
* {@inheritdoc}
*/
protected $defaultTheme = 'stark';
/**
* {@inheritdoc}
*/
public function testPatchPath(): void {
// Deserialization of the XML format is not supported.
$this->markTestSkipped();
}
}

View File

@@ -0,0 +1,53 @@
<?php
declare(strict_types=1);
namespace Drupal\Tests\workspaces\Functional\Rest;
use Drupal\Tests\rest\Functional\BasicAuthResourceTestTrait;
use Drupal\Tests\rest\Functional\EntityResource\XmlEntityNormalizationQuirksTrait;
/**
* Test workspace entities for XML requests with cookie authentication.
*
* @group workspaces
*/
class WorkspaceXmlBasicAuthTest extends WorkspaceResourceTestBase {
use BasicAuthResourceTestTrait;
use XmlEntityNormalizationQuirksTrait;
/**
* {@inheritdoc}
*/
protected static $modules = ['basic_auth'];
/**
* {@inheritdoc}
*/
protected $defaultTheme = 'stark';
/**
* {@inheritdoc}
*/
protected static $format = 'xml';
/**
* {@inheritdoc}
*/
protected static $mimeType = 'text/xml; charset=UTF-8';
/**
* {@inheritdoc}
*/
protected static $auth = 'basic_auth';
/**
* {@inheritdoc}
*/
public function testPatchPath(): void {
// Deserialization of the XML format is not supported.
$this->markTestSkipped();
}
}

View File

@@ -0,0 +1,48 @@
<?php
declare(strict_types=1);
namespace Drupal\Tests\workspaces\Functional\Rest;
use Drupal\Tests\rest\Functional\CookieResourceTestTrait;
use Drupal\Tests\rest\Functional\EntityResource\XmlEntityNormalizationQuirksTrait;
/**
* Test workspace entities for XML requests.
*
* @group workspaces
*/
class WorkspaceXmlCookieTest extends WorkspaceResourceTestBase {
use CookieResourceTestTrait;
use XmlEntityNormalizationQuirksTrait;
/**
* {@inheritdoc}
*/
protected static $format = 'xml';
/**
* {@inheritdoc}
*/
protected static $mimeType = 'text/xml; charset=UTF-8';
/**
* {@inheritdoc}
*/
protected static $auth = 'cookie';
/**
* {@inheritdoc}
*/
protected $defaultTheme = 'stark';
/**
* {@inheritdoc}
*/
public function testPatchPath(): void {
// Deserialization of the XML format is not supported.
$this->markTestSkipped();
}
}

View File

@@ -0,0 +1,77 @@
<?php
declare(strict_types=1);
namespace Drupal\Tests\workspaces\Functional\UpdateSystem;
use Drupal\Tests\BrowserTestBase;
use Drupal\Tests\UpdatePathTestTrait;
use Drupal\Tests\user\Traits\UserCreationTrait;
use Drupal\workspaces\Entity\Workspace;
/**
* Tests that there is no active workspace during database updates.
*
* @group workspaces
* @group Update
*/
class ActiveWorkspaceUpdateTest extends BrowserTestBase {
use UpdatePathTestTrait;
use UserCreationTrait;
/**
* {@inheritdoc}
*/
protected static $modules = ['workspaces'];
/**
* {@inheritdoc}
*/
protected $defaultTheme = 'stark';
/**
* {@inheritdoc}
*/
protected function setUp(): void {
parent::setUp();
$this->setUpCurrentUser([], ['view any workspace']);
$this->container->get('module_installer')->install(['workspace_update_test']);
$this->rebuildContainer();
// Ensure the workspace_update_test_post_update_check_active_workspace()
// update runs.
$existing_updates = \Drupal::keyValue('post_update')->get('existing_updates', []);
$index = array_search('workspace_update_test_post_update_check_active_workspace', $existing_updates);
unset($existing_updates[$index]);
\Drupal::keyValue('post_update')->set('existing_updates', $existing_updates);
// Create a valid workspace that can be used for testing.
Workspace::create(['id' => 'test', 'label' => 'Test'])->save();
}
/**
* Tests that there is no active workspace during database updates.
*/
public function testActiveWorkspaceDuringUpdate(): void {
/** @var \Drupal\workspaces\WorkspaceManagerInterface $workspace_manager */
$workspace_manager = \Drupal::service('workspaces.manager');
// Check that we have an active workspace before running the updates.
$this->assertTrue($workspace_manager->hasActiveWorkspace());
$this->assertEquals('test', $workspace_manager->getActiveWorkspace()->id());
$this->runUpdates();
// Check that we didn't have an active workspace while running the updates.
// @see workspace_update_test_post_update_check_active_workspace()
$this->assertFalse(\Drupal::state()->get('workspace_update_test.has_active_workspace'));
// Check that we have an active workspace after running the updates.
$workspace_manager = \Drupal::service('workspaces.manager');
$this->assertTrue($workspace_manager->hasActiveWorkspace());
$this->assertEquals('test', $workspace_manager->getActiveWorkspace()->id());
}
}

View File

@@ -0,0 +1,73 @@
<?php
declare(strict_types=1);
namespace Drupal\Tests\workspaces\Functional;
use Drupal\Tests\BrowserTestBase;
use Drupal\Tests\node\Traits\ContentTypeCreationTrait;
// cspell:ignore ditka
/**
* Tests access bypass permission controls on workspaces.
*
* @group workspaces
*/
class WorkspaceBypassTest extends BrowserTestBase {
use WorkspaceTestUtilities;
use ContentTypeCreationTrait;
/**
* {@inheritdoc}
*/
protected static $modules = ['node', 'user', 'block', 'workspaces'];
/**
* {@inheritdoc}
*/
protected $defaultTheme = 'stark';
/**
* Verifies that a user can edit anything in a workspace they own.
*/
public function testBypassOwnWorkspace(): void {
$permissions = [
'create workspace',
'edit own workspace',
'view own workspace',
'bypass entity access own workspace',
];
$this->createContentType(['type' => 'test', 'label' => 'Test']);
$this->setupWorkspaceSwitcherBlock();
$coach = $this->drupalCreateUser(array_merge($permissions, ['create test content']));
// Login as a limited-access user and create a workspace.
$this->drupalLogin($coach);
$bears = $this->createAndActivateWorkspaceThroughUi('Bears', 'bears');
// Now create a node in the Bears workspace, as the owner of that workspace.
$coach_bears_node = $this->createNodeThroughUi('Ditka Bears node', 'test');
$coach_bears_node_id = $coach_bears_node->id();
// Editing both nodes should be possible.
$this->drupalGet('/node/' . $coach_bears_node_id . '/edit');
$this->assertSession()->statusCodeEquals(200);
// Create a new user that should be able to edit anything in the Bears
// workspace.
$this->switchToLive();
$lombardi = $this->drupalCreateUser(array_merge($permissions, ['view any workspace']));
$this->drupalLogin($lombardi);
$this->switchToWorkspace($bears);
// Editor 2 has the bypass permission but does not own the workspace and so,
// should not be able to create and edit any node.
$this->drupalGet('/node/' . $coach_bears_node_id . '/edit');
$this->assertSession()->statusCodeEquals(403);
}
}

View File

@@ -0,0 +1,95 @@
<?php
declare(strict_types=1);
namespace Drupal\Tests\workspaces\Functional;
use Drupal\Core\Cache\CacheableMetadata;
use Drupal\Tests\BrowserTestBase;
use Drupal\Tests\system\Functional\Cache\AssertPageCacheContextsAndTagsTrait;
use Drupal\workspaces\Entity\Workspace;
use Drupal\workspaces\WorkspaceCacheContext;
/**
* Tests the workspace cache context.
*
* @group workspaces
* @group Cache
*/
class WorkspaceCacheContextTest extends BrowserTestBase {
use AssertPageCacheContextsAndTagsTrait;
/**
* {@inheritdoc}
*/
protected static $modules = ['block', 'node', 'workspaces'];
/**
* {@inheritdoc}
*/
protected $defaultTheme = 'stark';
/**
* Tests the 'workspace' cache context.
*/
public function testWorkspaceCacheContext(): void {
$renderer = \Drupal::service('renderer');
$cache_contexts_manager = \Drupal::service("cache_contexts_manager");
/** @var \Drupal\Core\Cache\VariationCacheFactoryInterface $variation_cache_factory */
$variation_cache_factory = $this->container->get('variation_cache_factory');
// Check that the 'workspace' cache context is present when the module is
// installed.
$this->drupalGet('<front>');
$this->assertCacheContext('workspace');
$cache_context = new WorkspaceCacheContext(\Drupal::service('workspaces.manager'));
$this->assertSame('live', $cache_context->getContext());
// Create a node and check that its render array contains the proper cache
// context.
$this->drupalCreateContentType(['type' => 'page']);
$node = $this->createNode();
// Get a fully built entity view render array.
$build = \Drupal::entityTypeManager()->getViewBuilder('node')->view($node, 'full');
// Render it so the default cache contexts are applied.
$renderer->renderRoot($build);
$this->assertContains('workspace', $build['#cache']['contexts']);
$context_tokens = $cache_contexts_manager->convertTokensToKeys($build['#cache']['contexts'])->getKeys();
$this->assertContains('[workspace]=live', $context_tokens);
// Test that a cache entry is created.
$cache_bin = $variation_cache_factory->get($build['#cache']['bin']);
$this->assertInstanceOf(\stdClass::class, $cache_bin->get($build['#cache']['keys'], CacheableMetadata::createFromRenderArray($build)));
// Switch to the 'stage' workspace and check that the correct workspace
// cache context is used.
$test_user = $this->drupalCreateUser(['view any workspace']);
$this->drupalLogin($test_user);
$stage = Workspace::load('stage');
$workspace_manager = \Drupal::service('workspaces.manager');
$workspace_manager->setActiveWorkspace($stage);
$cache_context = new WorkspaceCacheContext($workspace_manager);
$this->assertSame('stage', $cache_context->getContext());
$build = \Drupal::entityTypeManager()->getViewBuilder('node')->view($node, 'full');
// Render it so the default cache contexts are applied.
$renderer->renderRoot($build);
$this->assertContains('workspace', $build['#cache']['contexts']);
$context_tokens = $cache_contexts_manager->convertTokensToKeys($build['#cache']['contexts'])->getKeys();
$this->assertContains('[workspace]=stage', $context_tokens);
// Test that a cache entry is created.
$cache_bin = $variation_cache_factory->get($build['#cache']['bin']);
$this->assertInstanceOf(\stdClass::class, $cache_bin->get($build['#cache']['keys'], CacheableMetadata::createFromRenderArray($build)));
}
}

Some files were not shown because too many files have changed in this diff Show More